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