E-commerceArchitectureThe Good Guys

From Monolith to Microservices: The Good Guys 18-Month Transformation Journey

April 25, 2024 β€’ 16 min read

Transformation Challenges

  • βš–οΈLegacy .NET Monolith – 500K+ lines, 10+ years of organic growth
  • πŸͺ100+ Physical Stores – Real-time inventory across locations
  • πŸ’°$2B+ Annual Revenue – Zero downtime tolerance
  • πŸ‘₯50+ Developer Team – Coordination bottlenecks

Business Impact Results

  • πŸš€3x Faster Delivery – Feature release velocity improvement
  • ⚑60% API Performance – Response time improvements
  • πŸ“ˆ15% Conversion Increase – Better user experience
  • πŸ’Ž$2M+ Revenue Impact – Direct business value generated

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.

Monolith to Microservices Architecture Evolution

Before: Monolithic Architecture
Single .NET Application (500K+ lines)
Product Catalog
Inventory
Orders
Payments
User Management
Reporting
Single SQL Server Database
↓
After: Microservices Architecture
Product Service
Inventory Service
Order Service
Payment Service
User Service
Analytics Service
API Gateway (Kong)
Event Bus (RabbitMQ)
PostgreSQL
Redis Cache

The transformation from a 500K+ line monolith to domain-driven microservices improved development velocity by 3x and system reliability significantly.

Migration Strategy: The Strangler Fig Pattern

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();
    }
}

Data Migration & Database Per Service

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);
    }
}

API Gateway & Service Communication

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 };
    }
}

Domain-Driven Design Implementation

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;
    }
}

Migration Results & Lessons Learned

# 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
Share: