April 25, 2024 β’ 16 min read
When I joined The Good Guys in 2019, we were processing millions of transactions through a massive .NET monolith that had grown organically over a decade. As Australia's largest electrical retailer, any downtime meant significant revenue loss. What followed was an 18-month transformation journey that changed how we think about software architecture, team autonomy, and business agility.
The transformation from a 500K+ line monolith to domain-driven microservices improved development velocity by 3x and system reliability significantly.
We used the Strangler Fig pattern to gradually extract services without disrupting the existing system:
// 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(); } }
One of the biggest challenges was extracting data from the monolithic database while maintaining consistency:
// 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); } }
Kong API Gateway provided routing, authentication, and rate limiting for our microservices:
# 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 }; } }
We organized services around business domains rather than technical layers:
// 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