E-commerceArchitectureThe Good Guys

From monolith to microservices, without the dogma

April 25, 2024 • 16 min read

What we were dealing with

  • A .NET monolith – 500K+ lines, a decade of organic growth
  • 100+ physical stores – inventory that had to stay in sync
  • $2B+ in annual revenue – very little tolerance for downtime
  • 50+ developers – all stepping on each other in one codebase

Where it ended up

  • 3x faster delivery – idea to production
  • 60% faster APIs – on response times
  • 15% lift in conversion – mostly from faster pages
  • $2M+ revenue impact – attributable to shipping faster

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.

Before and after

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

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.

Strangling the monolith one route at a time

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

Pulling the data apart

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

The gateway and how services talk

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

Drawing the boundaries around domains

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

Results, and what I'd tell my past self

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