April 25, 2024 • 16 min read
I want to get one thing out of the way first: the monolith wasn't a mistake. Most platforms start as one for good reasons, and The Good Guys was no exception — a single .NET app got the business a long way before I joined in 2019. The case for splitting it up only became clear once 50-odd developers were queuing to deploy and a small change anywhere meant redeploying everything. This is the story of when we decided that pain was worth fixing, and what it took. It ran about 18 months.
Splitting the 500K-line monolith into services along domain lines roughly tripled how fast we could ship, and made the system easier to keep up.
A big-bang rewrite was never on the table — not on a system taking real orders. We used the strangler fig pattern: stand up a new service, route a slice of traffic to it behind a feature toggle, and keep the old path as a fallback until the new one earned trust. Product catalog went first because it was the most self-contained:
// Phase 1: Extract Product Catalog Service
// Legacy Monolith ProductController
[Route("api/products")]
public class ProductController : Controller
{
private readonly LegacyProductService _legacyService;
private readonly NewProductService _newService;
private readonly IFeatureToggle _featureToggle;
[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(int id)
{
// Strangler Fig: Route to new service gradually
if (await _featureToggle.IsEnabled("new-product-service", id))
{
var newProduct = await _newService.GetProductAsync(id);
return Ok(newProduct);
}
// Fallback to legacy system
var legacyProduct = await _legacyService.GetProduct(id);
return Ok(MapToNewFormat(legacyProduct));
}
}
// New Microservice: Product Service
[ApiController]
[Route("api/v1/products")]
public class ProductsController : ControllerBase
{
private readonly IProductRepository _repository;
private readonly IEventPublisher _eventPublisher;
[HttpGet("{id}")]
public async Task<ActionResult<ProductDto>> GetProduct(
[FromRoute] int id)
{
var product = await _repository.GetByIdAsync(id);
if (product == null)
return NotFound();
await _eventPublisher.PublishAsync(new ProductViewedEvent
{
ProductId = id,
ViewedAt = DateTime.UtcNow,
UserId = GetCurrentUserId()
});
return Ok(ProductDto.FromDomain(product));
}
[HttpPut("{id}")]
public async Task<ActionResult> UpdateProduct(
[FromRoute] int id,
[FromBody] UpdateProductRequest request)
{
var product = await _repository.GetByIdAsync(id);
if (product == null)
return NotFound();
product.UpdateDetails(request.Name, request.Description, request.Price);
await _repository.UpdateAsync(product);
await _eventPublisher.PublishAsync(new ProductUpdatedEvent
{
ProductId = id,
UpdatedFields = request.GetChangedFields(),
UpdatedAt = DateTime.UtcNow
});
return NoContent();
}
}The hardest part wasn't the code, it was the database. Everything shared one SQL Server, and giving each service its own store while keeping data consistent in the meantime was the part that kept me up at night. We migrated a table at a time, transformed into the new model, and emitted events so the other services could keep their own copies in step:
// Database Migration Strategy
public class ProductDataMigrationService
{
private readonly ILegacyDatabase _legacyDb;
private readonly IProductDatabase _productDb;
private readonly IEventStore _eventStore;
public async Task MigrateProductData()
{
// 1. Create read-only replica for migration
var products = await _legacyDb.GetAllProductsAsync();
foreach (var legacyProduct in products)
{
// 2. Transform to new domain model
var newProduct = new Product
{
Id = legacyProduct.Id,
Name = legacyProduct.ProductName,
Description = legacyProduct.ProductDescription,
Price = legacyProduct.CurrentPrice,
CategoryId = legacyProduct.CategoryId,
SKU = legacyProduct.ProductCode,
CreatedAt = legacyProduct.DateCreated,
UpdatedAt = legacyProduct.LastModified
};
// 3. Migrate to new database
await _productDb.CreateProductAsync(newProduct);
// 4. Create event for other services
await _eventStore.AppendAsync(new ProductMigratedEvent
{
ProductId = newProduct.Id,
MigratedAt = DateTime.UtcNow,
SourceSystem = "Legacy"
});
}
}
}
// Event-Driven Communication Between Services
public class InventoryService
{
private readonly IInventoryRepository _repository;
[EventHandler]
public async Task Handle(ProductCreatedEvent @event)
{
// Automatically create inventory record for new products
var inventory = new InventoryItem
{
ProductId = @event.ProductId,
Quantity = 0,
ReorderLevel = 10,
CreatedAt = DateTime.UtcNow
};
await _repository.CreateInventoryItemAsync(inventory);
}
[EventHandler]
public async Task Handle(ProductDeletedEvent @event)
{
// Clean up inventory when product is deleted
await _repository.DeleteByProductIdAsync(@event.ProductId);
}
}Once you have more than a couple of services you need something in front of them. Kong handled routing, auth and rate limiting so each service didn't have to reinvent that. Service-to-service calls went through discovery, with a fallback when a dependency was down so one sick service didn't take the rest with it:
# Kong API Gateway Configuration
# Product Service Route
kong:
services:
- name: product-service
url: http://product-service:8080
routes:
- name: product-api
paths: ["/api/v1/products"]
methods: ["GET", "POST", "PUT", "DELETE"]
plugins:
- name: rate-limiting
config:
minute: 1000
hour: 10000
- name: jwt
config:
secret_is_base64: false
- name: inventory-service
url: http://inventory-service:8080
routes:
- name: inventory-api
paths: ["/api/v1/inventory"]
plugins:
- name: correlation-id
- name: request-response-logging
# Service Discovery with Consul
public class ProductService
{
private readonly IServiceDiscovery _serviceDiscovery;
private readonly HttpClient _httpClient;
public async Task<InventoryStatus> GetInventoryStatus(int productId)
{
// Discover inventory service endpoint
var inventoryEndpoint = await _serviceDiscovery
.DiscoverServiceAsync("inventory-service");
var response = await _httpClient.GetAsync(
$"{inventoryEndpoint}/api/v1/inventory/product/{productId}");
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<InventoryStatus>(content);
}
// Circuit breaker: return default if service unavailable
return new InventoryStatus { Available = false, Quantity = 0 };
}
}The mistake I've seen elsewhere is slicing services by technical layer — a controllers service, a data service — which just gives you a distributed monolith. We drew the lines around business domains instead. Orders is a good example: the rules about what you can and can't do to an order live in the order aggregate, not scattered across the app:
// Domain Model: Order Aggregate
public class Order
{
private readonly List<OrderItem> _items = new();
private readonly List<DomainEvent> _events = new();
public OrderId Id { get; private set; }
public CustomerId CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
public Money TotalAmount { get; private set; }
public DateTime CreatedAt { get; private set; }
public void AddItem(ProductId productId, int quantity, Money unitPrice)
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Cannot modify confirmed order");
var existingItem = _items.FirstOrDefault(i => i.ProductId == productId);
if (existingItem != null)
{
existingItem.UpdateQuantity(existingItem.Quantity + quantity);
}
else
{
_items.Add(new OrderItem(productId, quantity, unitPrice));
}
RecalculateTotal();
_events.Add(new OrderItemAddedEvent(Id, productId, quantity));
}
public void Confirm()
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Order already confirmed");
if (!_items.Any())
throw new InvalidOperationException("Cannot confirm empty order");
Status = OrderStatus.Confirmed;
_events.Add(new OrderConfirmedEvent(Id, CustomerId, TotalAmount));
}
private void RecalculateTotal()
{
TotalAmount = Money.FromDecimal(
_items.Sum(i => i.UnitPrice.Amount * i.Quantity),
"AUD");
}
}
// Application Service
public class OrderService
{
private readonly IOrderRepository _orderRepository;
private readonly IInventoryService _inventoryService;
private readonly IPaymentService _paymentService;
private readonly IEventPublisher _eventPublisher;
public async Task<OrderId> CreateOrderAsync(CreateOrderCommand command)
{
// Validate inventory availability
foreach (var item in command.Items)
{
var available = await _inventoryService
.CheckAvailabilityAsync(item.ProductId, item.Quantity);
if (!available)
throw new InsufficientInventoryException(item.ProductId);
}
// Create order aggregate
var order = new Order(command.CustomerId);
foreach (var item in command.Items)
{
order.AddItem(item.ProductId, item.Quantity, item.UnitPrice);
}
// Persist order
await _orderRepository.SaveAsync(order);
// Publish domain events
foreach (var @event in order.GetDomainEvents())
{
await _eventPublisher.PublishAsync(@event);
}
return order.Id;
}
}# The Good Guys Microservices Migration Results (2019-2021) Timeline & Team Impact: • Phase 1 (Months 1-6): Product & Inventory services • Phase 2 (Months 7-12): Order & Payment services • Phase 3 (Months 13-18): User & Analytics services • Team structure: 8 domain teams, 3-5 developers each Technical Metrics: ┌─────────────────────────┬─────────────┬────────────────┬─────────────────┐ │ Metric │ Monolith │ Microservices │ Improvement │ ├─────────────────────────┼─────────────┼────────────────┼─────────────────┤ │ Deployment Frequency │ Monthly │ Daily │ 30x increase │ │ Lead Time (Idea→Prod) │ 8 weeks │ 2.5 weeks │ 3x faster │ │ Mean Recovery Time │ 4 hours │ 15 minutes │ 16x faster │ │ Service Availability │ 99.2% │ 99.7% │ 0.5% improvement│ │ API Response Time │ 850ms │ 340ms │ 60% faster │ └─────────────────────────┴─────────────┴────────────────┴─────────────────┘ Business Impact: ✅ $2M+ annual revenue increase from faster feature delivery ✅ 15% improvement in conversion rates (faster page loads) ✅ 75% reduction in cross-team dependencies ✅ 50% faster onboarding for new developers Key Lessons Learned: 🎯 Start with the most independent bounded contexts 📊 Invest heavily in observability and monitoring 🔄 Event-driven architecture enables loose coupling 👥 Conway's Law: Team structure drives architecture ⚡ Database migration is the hardest part - plan carefully 🛡️ Distributed systems bring new failure modes 📈 Microservices excel when you have autonomous teams