Domain-Driven Design in Go: Building Complex Business Systems

    Go Architecture Patterns Series: ← Hexagonal Architecture | Series Overview | Next: Modular Monolith → What is Domain-Driven Design? Domain-Driven Design (DDD) is a software development approach introduced by Eric Evans that focuses on creating software that matches the business domain. It emphasizes collaboration between technical and domain experts using a common language (Ubiquitous Language) and strategic/tactical patterns to handle complex business logic. Key Principles: Ubiquitous Language: Common language shared by developers and domain experts Bounded Contexts: Explicit boundaries where a particular domain model applies Domain Model: Rich model that captures business rules and behavior Strategic Design: High-level patterns for organizing large systems Tactical Design: Building blocks for implementing domain models DDD Strategic Patterns %%{init: {'theme':'dark', 'themeVariables': {'primaryTextColor':'#e5e7eb','secondaryTextColor':'#e5e7eb','tertiaryTextColor':'#e5e7eb','textColor':'#e5e7eb','nodeTextColor':'#e5e7eb','edgeLabelText':'#e5e7eb','clusterTextColor':'#e5e7eb','actorTextColor':'#e5e7eb'}}}%% graph TB subgraph "E-commerce System" subgraph "Ordering Context" OC[Order Aggregate] OL[Order Line Items] OP[Order Payment] end subgraph "Inventory Context" IC[Product Catalog] IS[Stock Management] IW[Warehouse] end subgraph "Shipping Context" SC[Shipment] SD[Delivery] ST[Tracking] end subgraph "Customer Context" CC[Customer Profile] CA[Address] CP[Preferences] end end OC -.->|Anti-Corruption Layer| IC OC -.->|Shared Kernel| CC SC -.->|Published Language| OC style OC fill:#78350f,color:#fff style IC fill:#1e3a5f,color:#fff style SC fill:#134e4a,color:#fff style CC fill:#4c1d95,color:#fff Bounded Context Map %%{init: {'theme':'dark', 'themeVariables': {'primaryTextColor':'#e5e7eb','secondaryTextColor':'#e5e7eb','tertiaryTextColor':'#e5e7eb','textColor':'#e5e7eb','nodeTextColor':'#e5e7eb','edgeLabelText':'#e5e7eb','clusterTextColor':'#e5e7eb','actorTextColor':'#e5e7eb'}}}%% graph LR subgraph "Sales Context" S[Sales DomainCustomer, Order, Product] end subgraph "Support Context" SP[Support DomainTicket, Customer, Issue] end subgraph "Billing Context" B[Billing DomainInvoice, Payment, Customer] end subgraph "Shared Kernel" SK[Customer Identity] end S -.->|Conformist| SK SP -.->|Customer/Supplier| S B -.->|Partnership| S style S fill:#78350f,color:#fff style SP fill:#1e3a5f,color:#fff style B fill:#134e4a,color:#fff style SK fill:#4c1d95,color:#fff DDD Tactical Patterns %%{init: {'theme':'dark', 'themeVariables': {'primaryTextColor':'#e5e7eb','secondaryTextColor':'#e5e7eb','tertiaryTextColor':'#e5e7eb','textColor':'#e5e7eb','nodeTextColor':'#e5e7eb','edgeLabelText':'#e5e7eb','clusterTextColor':'#e5e7eb','actorTextColor':'#e5e7eb'}}}%% graph TD subgraph "Aggregate Root" AR[OrderAggregate Root] E1[Order LineEntity] E2[Payment InfoEntity] VO1[MoneyValue Object] VO2[AddressValue Object] end subgraph "Domain Services" DS[Pricing Service] DS2[Shipping Calculator] end subgraph "Repositories" R[Order Repository] end AR --> E1 AR --> E2 E1 --> VO1 AR --> VO2 AR -.->|uses| DS R -.->|persists| AR style AR fill:#78350f,stroke:#fb923c,stroke-width:3px,color:#fff style VO1 fill:#1e3a5f,color:#fff style DS fill:#134e4a,color:#fff style R fill:#4c1d95,color:#fff Real-World Use Cases E-commerce Platforms: Complex ordering, inventory, and payment systems Financial Systems: Banking, trading, and payment processing Healthcare Systems: Patient records, appointments, and billing Supply Chain Management: Inventory, shipping, and logistics Enterprise Resource Planning: Multi-domain business systems Insurance Systems: Policy management, claims, and underwriting Project Structure ├── cmd/ │ └── api/ │ └── main.go ├── internal/ │ ├── domain/ │ │ ├── order/ # Order Bounded Context │ │ │ ├── aggregate/ │ │ │ │ └── order.go # Order aggregate root │ │ │ ├── entity/ │ │ │ │ └── order_line.go │ │ │ ├── valueobject/ │ │ │ │ ├── money.go │ │ │ │ └── quantity.go │ │ │ ├── service/ │ │ │ │ └── pricing_service.go │ │ │ ├── repository/ │ │ │ │ └── order_repository.go │ │ │ └── event/ │ │ │ └── order_placed.go │ │ ├── customer/ # Customer Bounded Context │ │ │ ├── aggregate/ │ │ │ └── valueobject/ │ │ └── shared/ # Shared Kernel │ │ └── valueobject/ │ ├── application/ # Application Services │ │ ├── order/ │ │ │ └── order_service.go │ │ └── customer/ │ │ └── customer_service.go │ └── infrastructure/ │ ├── persistence/ │ └── messaging/ └── go.mod Building Blocks: Value Objects package valueobject import ( "errors" "fmt" ) // Money represents a monetary value (Value Object) // Value objects are immutable and compared by value, not identity type Money struct { amount int64 // Amount in smallest currency unit (cents) currency string } // NewMoney creates a new Money value object func NewMoney(amount int64, currency string) (Money, error) { if currency == "" { return Money{}, errors.New("currency cannot be empty") } if amount < 0 { return Money{}, errors.New("amount cannot be negative") } return Money{amount: amount, currency: currency}, nil } // Amount returns the amount func (m Money) Amount() int64 { return m.amount } // Currency returns the currency func (m Money) Currency() string { return m.currency } // Add adds two money values func (m Money) Add(other Money) (Money, error) { if m.currency != other.currency { return Money{}, errors.New("cannot add different currencies") } return Money{ amount: m.amount + other.amount, currency: m.currency, }, nil } // Multiply multiplies money by a quantity func (m Money) Multiply(multiplier int) Money { return Money{ amount: m.amount * int64(multiplier), currency: m.currency, } } // IsZero checks if money is zero func (m Money) IsZero() bool { return m.amount == 0 } // Equals checks equality (value objects compare by value) func (m Money) Equals(other Money) bool { return m.amount == other.amount && m.currency == other.currency } // String returns string representation func (m Money) String() string { return fmt.Sprintf("%d %s", m.amount, m.currency) } // Email represents an email address (Value Object) type Email struct { value string } // NewEmail creates a new Email value object func NewEmail(email string) (Email, error) { if !isValidEmail(email) { return Email{}, errors.New("invalid email format") } return Email{value: email}, nil } // String returns the email string func (e Email) String() string { return e.value } // Equals checks equality func (e Email) Equals(other Email) bool { return e.value == other.value } func isValidEmail(email string) bool { // Simplified validation return len(email) > 3 && contains(email, "@") && contains(email, ".") } func contains(s, substr string) bool { for i := 0; i <= len(s)-len(substr); i++ { if s[i:i+len(substr)] == substr { return true } } return false } // Address represents a physical address (Value Object) type Address struct { street string city string state string postalCode string country string } // NewAddress creates a new Address value object func NewAddress(street, city, state, postalCode, country string) (Address, error) { if street == "" || city == "" || country == "" { return Address{}, errors.New("street, city, and country are required") } return Address{ street: street, city: city, state: state, postalCode: postalCode, country: country, }, nil } // Street returns the street func (a Address) Street() string { return a.street } // City returns the city func (a Address) City() string { return a.city } // Country returns the country func (a Address) Country() string { return a.country } // Quantity represents a product quantity (Value Object) type Quantity struct { value int } // NewQuantity creates a new Quantity value object func NewQuantity(value int) (Quantity, error) { if value < 0 { return Quantity{}, errors.New("quantity cannot be negative") } return Quantity{value: value}, nil } // Value returns the quantity value func (q Quantity) Value() int { return q.value } // Add adds two quantities func (q Quantity) Add(other Quantity) Quantity { return Quantity{value: q.value + other.value} } // IsZero checks if quantity is zero func (q Quantity) IsZero() bool { return q.value == 0 } Building Blocks: Entities package entity import ( "time" "myapp/internal/domain/order/valueobject" ) // OrderLine is an entity (has identity) // Entities have identity and lifecycle type OrderLine struct { id string productID string product string quantity valueobject.Quantity unitPrice valueobject.Money createdAt time.Time } // NewOrderLine creates a new order line entity func NewOrderLine(id, productID, product string, quantity valueobject.Quantity, unitPrice valueobject.Money) *OrderLine { return &OrderLine{ id: id, productID: productID, product: product, quantity: quantity, unitPrice: unitPrice, createdAt: time.Now(), } } // ID returns the order line ID (identity) func (ol *OrderLine) ID() string { return ol.id } // ProductID returns the product ID func (ol *OrderLine) ProductID() string { return ol.productID } // Quantity returns the quantity func (ol *OrderLine) Quantity() valueobject.Quantity { return ol.quantity } // UnitPrice returns the unit price func (ol *OrderLine) UnitPrice() valueobject.Money { return ol.unitPrice } // TotalPrice calculates the total price for this line func (ol *OrderLine) TotalPrice() valueobject.Money { return ol.unitPrice.Multiply(ol.quantity.Value()) } // UpdateQuantity updates the quantity func (ol *OrderLine) UpdateQuantity(newQuantity valueobject.Quantity) { ol.quantity = newQuantity } // Payment is an entity representing payment information type Payment struct { id string method PaymentMethod amount valueobject.Money transactionID string status PaymentStatus paidAt time.Time } // PaymentMethod represents payment method type PaymentMethod string const ( PaymentMethodCreditCard PaymentMethod = "credit_card" PaymentMethodDebitCard PaymentMethod = "debit_card" PaymentMethodPayPal PaymentMethod = "paypal" ) // PaymentStatus represents payment status type PaymentStatus string const ( PaymentStatusPending PaymentStatus = "pending" PaymentStatusCompleted PaymentStatus = "completed" PaymentStatusFailed PaymentStatus = "failed" ) Building Blocks: Aggregates package aggregate import ( "errors" "time" "myapp/internal/domain/order/entity" "myapp/internal/domain/order/event" "myapp/internal/domain/order/valueobject" ) // Order is an aggregate root // Aggregate roots maintain consistency boundaries and control access to entities type Order struct { id string customerID string orderLines []*entity.OrderLine shippingAddress valueobject.Address billingAddress valueobject.Address payment *entity.Payment status OrderStatus total valueobject.Money createdAt time.Time updatedAt time.Time // Domain events events []event.DomainEvent } // OrderStatus represents the status of an order type OrderStatus string const ( OrderStatusDraft OrderStatus = "draft" OrderStatusPlaced OrderStatus = "placed" OrderStatusConfirmed OrderStatus = "confirmed" OrderStatusShipped OrderStatus = "shipped" OrderStatusDelivered OrderStatus = "delivered" OrderStatusCancelled OrderStatus = "cancelled" ) // NewOrder creates a new order aggregate func NewOrder(id, customerID string, shippingAddress, billingAddress valueobject.Address) (*Order, error) { if id == "" { return nil, errors.New("order ID is required") } if customerID == "" { return nil, errors.New("customer ID is required") } return &Order{ id: id, customerID: customerID, orderLines: make([]*entity.OrderLine, 0), shippingAddress: shippingAddress, billingAddress: billingAddress, status: OrderStatusDraft, createdAt: time.Now(), updatedAt: time.Now(), events: make([]event.DomainEvent, 0), }, nil } // ID returns the order ID func (o *Order) ID() string { return o.id } // CustomerID returns the customer ID func (o *Order) CustomerID() string { return o.customerID } // Status returns the order status func (o *Order) Status() OrderStatus { return o.status } // Total returns the total amount func (o *Order) Total() valueobject.Money { return o.total } // AddOrderLine adds an order line to the order (aggregate invariant) func (o *Order) AddOrderLine(orderLine *entity.OrderLine) error { // Business rule: Cannot add lines to non-draft orders if o.status != OrderStatusDraft { return errors.New("cannot add items to non-draft order") } // Business rule: Check for duplicate products for _, line := range o.orderLines { if line.ProductID() == orderLine.ProductID() { return errors.New("product already exists in order") } } o.orderLines = append(o.orderLines, orderLine) o.recalculateTotal() o.updatedAt = time.Now() return nil } // RemoveOrderLine removes an order line (aggregate invariant) func (o *Order) RemoveOrderLine(orderLineID string) error { // Business rule: Cannot remove lines from non-draft orders if o.status != OrderStatusDraft { return errors.New("cannot remove items from non-draft order") } for i, line := range o.orderLines { if line.ID() == orderLineID { o.orderLines = append(o.orderLines[:i], o.orderLines[i+1:]...) o.recalculateTotal() o.updatedAt = time.Now() return nil } } return errors.New("order line not found") } // PlaceOrder places the order (state transition) func (o *Order) PlaceOrder() error { // Business rule: Can only place draft orders if o.status != OrderStatusDraft { return errors.New("can only place draft orders") } // Business rule: Order must have at least one line if len(o.orderLines) == 0 { return errors.New("order must have at least one item") } // Business rule: Order must have payment if o.payment == nil { return errors.New("order must have payment information") } o.status = OrderStatusPlaced o.updatedAt = time.Now() // Raise domain event o.addEvent(event.NewOrderPlacedEvent(o.id, o.customerID, o.total)) return nil } // ConfirmOrder confirms the order func (o *Order) ConfirmOrder() error { if o.status != OrderStatusPlaced { return errors.New("can only confirm placed orders") } o.status = OrderStatusConfirmed o.updatedAt = time.Now() o.addEvent(event.NewOrderConfirmedEvent(o.id)) return nil } // ShipOrder marks the order as shipped func (o *Order) ShipOrder() error { if o.status != OrderStatusConfirmed { return errors.New("can only ship confirmed orders") } o.status = OrderStatusShipped o.updatedAt = time.Now() o.addEvent(event.NewOrderShippedEvent(o.id, o.shippingAddress)) return nil } // CancelOrder cancels the order func (o *Order) CancelOrder(reason string) error { // Business rule: Cannot cancel shipped or delivered orders if o.status == OrderStatusShipped || o.status == OrderStatusDelivered { return errors.New("cannot cancel shipped or delivered orders") } if o.status == OrderStatusCancelled { return errors.New("order is already cancelled") } o.status = OrderStatusCancelled o.updatedAt = time.Now() o.addEvent(event.NewOrderCancelledEvent(o.id, reason)) return nil } // AddPayment adds payment to the order func (o *Order) AddPayment(payment *entity.Payment) error { if o.payment != nil { return errors.New("payment already exists") } // Business rule: Payment amount must match order total if !payment.Amount.Equals(o.total) { return errors.New("payment amount must match order total") } o.payment = payment o.updatedAt = time.Now() return nil } // recalculateTotal recalculates the order total func (o *Order) recalculateTotal() { if len(o.orderLines) == 0 { o.total = valueobject.Money{} return } total := o.orderLines[0].TotalPrice() for i := 1; i < len(o.orderLines); i++ { var err error total, err = total.Add(o.orderLines[i].TotalPrice()) if err != nil { // Handle error - in production, log this return } } o.total = total } // GetDomainEvents returns all domain events func (o *Order) GetDomainEvents() []event.DomainEvent { return o.events } // ClearDomainEvents clears all domain events func (o *Order) ClearDomainEvents() { o.events = make([]event.DomainEvent, 0) } // addEvent adds a domain event func (o *Order) addEvent(e event.DomainEvent) { o.events = append(o.events, e) } // OrderLines returns a copy of order lines func (o *Order) OrderLines() []*entity.OrderLine { // Return copy to prevent external modification lines := make([]*entity.OrderLine, len(o.orderLines)) copy(lines, o.orderLines) return lines } Building Blocks: Domain Events package event import ( "time" "myapp/internal/domain/order/valueobject" ) // DomainEvent is the base interface for all domain events type DomainEvent interface { OccurredAt() time.Time EventType() string } // OrderPlacedEvent is raised when an order is placed type OrderPlacedEvent struct { orderID string customerID string total valueobject.Money occurredAt time.Time } // NewOrderPlacedEvent creates a new OrderPlacedEvent func NewOrderPlacedEvent(orderID, customerID string, total valueobject.Money) *OrderPlacedEvent { return &OrderPlacedEvent{ orderID: orderID, customerID: customerID, total: total, occurredAt: time.Now(), } } // OrderID returns the order ID func (e *OrderPlacedEvent) OrderID() string { return e.orderID } // CustomerID returns the customer ID func (e *OrderPlacedEvent) CustomerID() string { return e.customerID } // Total returns the total amount func (e *OrderPlacedEvent) Total() valueobject.Money { return e.total } // OccurredAt returns when the event occurred func (e *OrderPlacedEvent) OccurredAt() time.Time { return e.occurredAt } // EventType returns the event type func (e *OrderPlacedEvent) EventType() string { return "OrderPlaced" } // OrderConfirmedEvent is raised when an order is confirmed type OrderConfirmedEvent struct { orderID string occurredAt time.Time } // NewOrderConfirmedEvent creates a new OrderConfirmedEvent func NewOrderConfirmedEvent(orderID string) *OrderConfirmedEvent { return &OrderConfirmedEvent{ orderID: orderID, occurredAt: time.Now(), } } // OrderID returns the order ID func (e *OrderConfirmedEvent) OrderID() string { return e.orderID } // OccurredAt returns when the event occurred func (e *OrderConfirmedEvent) OccurredAt() time.Time { return e.occurredAt } // EventType returns the event type func (e *OrderConfirmedEvent) EventType() string { return "OrderConfirmed" } // OrderShippedEvent is raised when an order is shipped type OrderShippedEvent struct { orderID string shippingAddress valueobject.Address occurredAt time.Time } // NewOrderShippedEvent creates a new OrderShippedEvent func NewOrderShippedEvent(orderID string, shippingAddress valueobject.Address) *OrderShippedEvent { return &OrderShippedEvent{ orderID: orderID, shippingAddress: shippingAddress, occurredAt: time.Now(), } } // EventType returns the event type func (e *OrderShippedEvent) EventType() string { return "OrderShipped" } // OccurredAt returns when the event occurred func (e *OrderShippedEvent) OccurredAt() time.Time { return e.occurredAt } // OrderCancelledEvent is raised when an order is cancelled type OrderCancelledEvent struct { orderID string reason string occurredAt time.Time } // NewOrderCancelledEvent creates a new OrderCancelledEvent func NewOrderCancelledEvent(orderID, reason string) *OrderCancelledEvent { return &OrderCancelledEvent{ orderID: orderID, reason: reason, occurredAt: time.Now(), } } // EventType returns the event type func (e *OrderCancelledEvent) EventType() string { return "OrderCancelled" } // OccurredAt returns when the event occurred func (e *OrderCancelledEvent) OccurredAt() time.Time { return e.occurredAt } Building Blocks: Domain Services package service import ( "errors" "myapp/internal/domain/order/valueobject" ) // PricingService is a domain service for calculating prices // Domain services contain business logic that doesn't belong to any entity type PricingService struct { taxRate float64 } // NewPricingService creates a new pricing service func NewPricingService(taxRate float64) *PricingService { return &PricingService{taxRate: taxRate} } // CalculateOrderTotal calculates the total price with tax and shipping func (s *PricingService) CalculateOrderTotal( subtotal valueobject.Money, shippingCost valueobject.Money, ) (valueobject.Money, error) { // Add shipping to subtotal total, err := subtotal.Add(shippingCost) if err != nil { return valueobject.Money{}, err } // Calculate tax taxAmount := int64(float64(total.Amount()) * s.taxRate) tax, err := valueobject.NewMoney(taxAmount, total.Currency()) if err != nil { return valueobject.Money{}, err } // Add tax to total return total.Add(tax) } // ApplyDiscount applies discount to money func (s *PricingService) ApplyDiscount(amount valueobject.Money, discountPercent int) (valueobject.Money, error) { if discountPercent < 0 || discountPercent > 100 { return valueobject.Money{}, errors.New("invalid discount percentage") } discountAmount := amount.Amount() * int64(discountPercent) / 100 finalAmount := amount.Amount() - discountAmount return valueobject.NewMoney(finalAmount, amount.Currency()) } // ShippingCalculator is a domain service for calculating shipping costs type ShippingCalculator struct{} // NewShippingCalculator creates a new shipping calculator func NewShippingCalculator() *ShippingCalculator { return &ShippingCalculator{} } // CalculateShippingCost calculates shipping cost based on weight and distance func (s *ShippingCalculator) CalculateShippingCost( weightKg float64, distanceKm float64, currency string, ) (valueobject.Money, error) { // Simple formula: base rate + weight factor + distance factor baseRate := int64(500) // $5.00 base weightFactor := int64(weightKg * 100) distanceFactor := int64(distanceKm * 10) totalCost := baseRate + weightFactor + distanceFactor return valueobject.NewMoney(totalCost, currency) } Building Blocks: Repositories package repository import ( "context" "myapp/internal/domain/order/aggregate" ) // OrderRepository defines the repository interface for orders // Repositories provide collection-like interface for aggregates type OrderRepository interface { // Save saves an order aggregate Save(ctx context.Context, order *aggregate.Order) error // FindByID finds an order by ID FindByID(ctx context.Context, id string) (*aggregate.Order, error) // FindByCustomerID finds orders by customer ID FindByCustomerID(ctx context.Context, customerID string) ([]*aggregate.Order, error) // Update updates an existing order Update(ctx context.Context, order *aggregate.Order) error // Delete deletes an order Delete(ctx context.Context, id string) error // NextIdentity generates the next order identity NextIdentity() string } Application Service package application import ( "context" "fmt" "myapp/internal/domain/order/aggregate" "myapp/internal/domain/order/entity" "myapp/internal/domain/order/repository" "myapp/internal/domain/order/service" "myapp/internal/domain/order/valueobject" ) // OrderService is an application service that orchestrates use cases // Application services coordinate domain objects and infrastructure type OrderService struct { orderRepo repository.OrderRepository pricingService *service.PricingService shippingCalculator *service.ShippingCalculator eventPublisher EventPublisher } // EventPublisher publishes domain events type EventPublisher interface { Publish(ctx context.Context, events []event.DomainEvent) error } // NewOrderService creates a new order service func NewOrderService( orderRepo repository.OrderRepository, pricingService *service.PricingService, shippingCalculator *service.ShippingCalculator, eventPublisher EventPublisher, ) *OrderService { return &OrderService{ orderRepo: orderRepo, pricingService: pricingService, shippingCalculator: shippingCalculator, eventPublisher: eventPublisher, } } // CreateOrderCommand represents the command to create an order type CreateOrderCommand struct { CustomerID string ShippingAddress AddressDTO BillingAddress AddressDTO Items []OrderItemDTO } // AddressDTO is a data transfer object for address type AddressDTO struct { Street string City string State string PostalCode string Country string } // OrderItemDTO is a data transfer object for order items type OrderItemDTO struct { ProductID string Product string Quantity int UnitPrice MoneyDTO } // MoneyDTO is a data transfer object for money type MoneyDTO struct { Amount int64 Currency string } // CreateOrder creates a new order (use case) func (s *OrderService) CreateOrder(ctx context.Context, cmd CreateOrderCommand) (string, error) { // Convert DTOs to value objects shippingAddr, err := valueobject.NewAddress( cmd.ShippingAddress.Street, cmd.ShippingAddress.City, cmd.ShippingAddress.State, cmd.ShippingAddress.PostalCode, cmd.ShippingAddress.Country, ) if err != nil { return "", fmt.Errorf("invalid shipping address: %w", err) } billingAddr, err := valueobject.NewAddress( cmd.BillingAddress.Street, cmd.BillingAddress.City, cmd.BillingAddress.State, cmd.BillingAddress.PostalCode, cmd.BillingAddress.Country, ) if err != nil { return "", fmt.Errorf("invalid billing address: %w", err) } // Create order aggregate orderID := s.orderRepo.NextIdentity() order, err := aggregate.NewOrder(orderID, cmd.CustomerID, shippingAddr, billingAddr) if err != nil { return "", err } // Add order lines for _, item := range cmd.Items { quantity, err := valueobject.NewQuantity(item.Quantity) if err != nil { return "", err } unitPrice, err := valueobject.NewMoney(item.UnitPrice.Amount, item.UnitPrice.Currency) if err != nil { return "", err } orderLine := entity.NewOrderLine( fmt.Sprintf("%s-line-%s", orderID, item.ProductID), item.ProductID, item.Product, quantity, unitPrice, ) if err := order.AddOrderLine(orderLine); err != nil { return "", err } } // Save order if err := s.orderRepo.Save(ctx, order); err != nil { return "", fmt.Errorf("failed to save order: %w", err) } return orderID, nil } // PlaceOrder places an order (use case) func (s *OrderService) PlaceOrder(ctx context.Context, orderID string, payment PaymentDTO) error { // Load order aggregate order, err := s.orderRepo.FindByID(ctx, orderID) if err != nil { return fmt.Errorf("order not found: %w", err) } // Create payment entity paymentAmount, err := valueobject.NewMoney(payment.Amount, payment.Currency) if err != nil { return err } paymentEntity := &entity.Payment{ // Payment entity fields } // Add payment to order if err := order.AddPayment(paymentEntity); err != nil { return err } // Place order (this enforces business rules) if err := order.PlaceOrder(); err != nil { return err } // Save order if err := s.orderRepo.Update(ctx, order); err != nil { return fmt.Errorf("failed to update order: %w", err) } // Publish domain events events := order.GetDomainEvents() if err := s.eventPublisher.Publish(ctx, events); err != nil { // Log error but don't fail the transaction fmt.Printf("failed to publish events: %v\n", err) } order.ClearDomainEvents() return nil } // PaymentDTO is a data transfer object for payment type PaymentDTO struct { Amount int64 Currency string Method string } Best Practices Ubiquitous Language: Use domain language in code and conversations Bounded Contexts: Define clear boundaries between contexts Aggregate Boundaries: Keep aggregates small and focused Immutable Value Objects: Make value objects immutable Domain Events: Use events to communicate between aggregates Repository per Aggregate: One repository per aggregate root Anemic Models: Avoid anemic domain models - put logic in entities Common Pitfalls Large Aggregates: Creating aggregates that are too large Breaking Invariants: Modifying entities without going through aggregate root Transaction Boundaries: Spanning transactions across multiple aggregates Ignoring Ubiquitous Language: Not collaborating with domain experts Over-engineering: Applying DDD to simple CRUD applications Missing Bounded Contexts: Not identifying context boundaries When to Use DDD Use When: ...

    January 22, 2025 · 18 min · Rafiul Alam

    Attention is All You Need: Visualized and Explained

    Introduction: The Paper That Changed Everything In 2017, Google researchers published “Attention is All You Need”, introducing the Transformer architecture. This single paper: Eliminated recurrence in sequence modeling Introduced pure attention mechanisms Enabled massive parallelization Became the foundation for GPT, BERT, and all modern LLMs Let’s visualize and demystify this revolutionary architecture, piece by piece. The Problem: Sequential Processing is Slow Before Transformers: RNNs and LSTMs graph LR A[Word 1The] --> B[Hidden h1] B --> C[Word 2cat] C --> D[Hidden h2] D --> E[Word 3sat] E --> F[Hidden h3] style B fill:#e74c3c style D fill:#e74c3c style F fill:#e74c3c Problem: Sequential processing-each step depends on the previous. Can’t parallelize! ...

    January 21, 2025 · 11 min · Rafiul Alam

    Modular Monolith Architecture in Go: Scaling Without Microservices

    Go Architecture Patterns Series: ← Previous: Domain-Driven Design | Series Overview | Next: Microservices Architecture → What is Modular Monolith Architecture? Modular Monolith Architecture is an approach that combines the simplicity of monolithic deployment with the modularity of microservices. It organizes code into independent, loosely coupled modules with well-defined boundaries, all deployed as a single application. Key Principles: Module Independence: Each module is self-contained with its own domain logic Clear Boundaries: Modules communicate through well-defined interfaces Shared Deployment: All modules deployed together in a single process Domain Alignment: Modules organized around business capabilities Internal APIs: Modules expose APIs for inter-module communication Data Ownership: Each module owns its data and database schema Architecture Overview %%{init: {'theme':'dark', 'themeVariables': {'primaryTextColor':'#e5e7eb','secondaryTextColor':'#e5e7eb','tertiaryTextColor':'#e5e7eb','textColor':'#e5e7eb','nodeTextColor':'#e5e7eb','edgeLabelText':'#e5e7eb','clusterTextColor':'#e5e7eb','actorTextColor':'#e5e7eb'}}}%% graph TD subgraph "Modular Monolith Application" API[API Gateway/Router] subgraph "User Module" U1[User Service] U2[User Repository] U3[(User DB Schema)] end subgraph "Order Module" O1[Order Service] O2[Order Repository] O3[(Order DB Schema)] end subgraph "Product Module" P1[Product Service] P2[Product Repository] P3[(Product DB Schema)] end subgraph "Payment Module" PA1[Payment Service] PA2[Payment Repository] PA3[(Payment DB Schema)] end API --> U1 API --> O1 API --> P1 API --> PA1 U1 --> U2 O1 --> O2 P1 --> P2 PA1 --> PA2 U2 --> U3 O2 --> O3 P2 --> P3 PA2 --> PA3 O1 -.->|Module API| U1 O1 -.->|Module API| P1 O1 -.->|Module API| PA1 end style U1 fill:#1e3a5f,color:#fff style O1 fill:#78350f,color:#fff style P1 fill:#134e4a,color:#fff style PA1 fill:#4c1d95,color:#fff Module Communication Patterns %%{init: {'theme':'dark', 'themeVariables': {'primaryTextColor':'#e5e7eb','secondaryTextColor':'#e5e7eb','tertiaryTextColor':'#e5e7eb','textColor':'#e5e7eb','nodeTextColor':'#e5e7eb','edgeLabelText':'#e5e7eb','clusterTextColor':'#e5e7eb','actorTextColor':'#e5e7eb'}}}%% sequenceDiagram participant Client participant OrderModule participant UserModule participant ProductModule participant PaymentModule Client->>OrderModule: Create Order OrderModule->>UserModule: Validate User UserModule-->>OrderModule: User Valid OrderModule->>ProductModule: Check Stock ProductModule-->>OrderModule: Stock Available OrderModule->>ProductModule: Reserve Items ProductModule-->>OrderModule: Items Reserved OrderModule->>PaymentModule: Process Payment PaymentModule-->>OrderModule: Payment Success OrderModule->>ProductModule: Confirm Reservation ProductModule-->>OrderModule: Confirmed OrderModule-->>Client: Order Created Real-World Use Cases E-commerce Platforms: Product, order, inventory, and payment management SaaS Applications: Multi-tenant applications with distinct features Content Management Systems: Content, media, user, and workflow modules Banking Systems: Account, transaction, loan, and reporting modules Healthcare Systems: Patient, appointment, billing, and medical records Enterprise Applications: HR, finance, inventory, and CRM modules Modular Monolith Implementation Project Structure ├── cmd/ │ └── app/ │ └── main.go ├── internal/ │ ├── modules/ │ │ ├── user/ │ │ │ ├── domain/ │ │ │ │ ├── user.go │ │ │ │ └── repository.go │ │ │ ├── application/ │ │ │ │ └── service.go │ │ │ ├── infrastructure/ │ │ │ │ └── postgres_repository.go │ │ │ ├── api/ │ │ │ │ └── http_handler.go │ │ │ └── module.go │ │ ├── order/ │ │ │ ├── domain/ │ │ │ │ ├── order.go │ │ │ │ └── repository.go │ │ │ ├── application/ │ │ │ │ └── service.go │ │ │ ├── infrastructure/ │ │ │ │ └── postgres_repository.go │ │ │ ├── api/ │ │ │ │ └── http_handler.go │ │ │ └── module.go │ │ ├── product/ │ │ │ ├── domain/ │ │ │ │ ├── product.go │ │ │ │ └── repository.go │ │ │ ├── application/ │ │ │ │ └── service.go │ │ │ ├── infrastructure/ │ │ │ │ └── postgres_repository.go │ │ │ ├── api/ │ │ │ │ └── http_handler.go │ │ │ └── module.go │ │ └── payment/ │ │ ├── domain/ │ │ │ ├── payment.go │ │ │ └── repository.go │ │ ├── application/ │ │ │ └── service.go │ │ ├── infrastructure/ │ │ │ └── postgres_repository.go │ │ ├── api/ │ │ │ └── http_handler.go │ │ └── module.go │ └── shared/ │ ├── database/ │ │ └── postgres.go │ └── events/ │ └── event_bus.go └── go.mod Module 1: User Module // internal/modules/user/domain/user.go package domain import ( "context" "errors" "time" ) type UserID string type User struct { ID UserID Email string Name string Active bool CreatedAt time.Time UpdatedAt time.Time } var ( ErrUserNotFound = errors.New("user not found") ErrUserAlreadyExists = errors.New("user already exists") ErrInvalidEmail = errors.New("invalid email") ) // Repository defines the interface for user storage type Repository interface { Create(ctx context.Context, user *User) error GetByID(ctx context.Context, id UserID) (*User, error) GetByEmail(ctx context.Context, email string) (*User, error) Update(ctx context.Context, user *User) error Delete(ctx context.Context, id UserID) error } // internal/modules/user/application/service.go package application import ( "context" "fmt" "regexp" "app/internal/modules/user/domain" ) type Service struct { repo domain.Repository } func NewService(repo domain.Repository) *Service { return &Service{repo: repo} } func (s *Service) CreateUser(ctx context.Context, email, name string) (*domain.User, error) { if !isValidEmail(email) { return nil, domain.ErrInvalidEmail } // Check if user exists existing, _ := s.repo.GetByEmail(ctx, email) if existing != nil { return nil, domain.ErrUserAlreadyExists } user := &domain.User{ ID: domain.UserID(generateID()), Email: email, Name: name, Active: true, CreatedAt: time.Now(), UpdatedAt: time.Now(), } if err := s.repo.Create(ctx, user); err != nil { return nil, fmt.Errorf("failed to create user: %w", err) } return user, nil } func (s *Service) GetUser(ctx context.Context, id domain.UserID) (*domain.User, error) { return s.repo.GetByID(ctx, id) } func (s *Service) ValidateUser(ctx context.Context, id domain.UserID) (bool, error) { user, err := s.repo.GetByID(ctx, id) if err != nil { return false, err } return user.Active, nil } func isValidEmail(email string) bool { emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) return emailRegex.MatchString(email) } func generateID() string { return fmt.Sprintf("user_%d", time.Now().UnixNano()) } // internal/modules/user/infrastructure/postgres_repository.go package infrastructure import ( "context" "database/sql" "fmt" "app/internal/modules/user/domain" ) type PostgresRepository struct { db *sql.DB } func NewPostgresRepository(db *sql.DB) *PostgresRepository { return &PostgresRepository{db: db} } func (r *PostgresRepository) Create(ctx context.Context, user *domain.User) error { query := ` INSERT INTO users.users (id, email, name, active, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6) ` _, err := r.db.ExecContext(ctx, query, user.ID, user.Email, user.Name, user.Active, user.CreatedAt, user.UpdatedAt) return err } func (r *PostgresRepository) GetByID(ctx context.Context, id domain.UserID) (*domain.User, error) { query := ` SELECT id, email, name, active, created_at, updated_at FROM users.users WHERE id = $1 ` user := &domain.User{} err := r.db.QueryRowContext(ctx, query, id).Scan( &user.ID, &user.Email, &user.Name, &user.Active, &user.CreatedAt, &user.UpdatedAt, ) if err == sql.ErrNoRows { return nil, domain.ErrUserNotFound } return user, err } func (r *PostgresRepository) GetByEmail(ctx context.Context, email string) (*domain.User, error) { query := ` SELECT id, email, name, active, created_at, updated_at FROM users.users WHERE email = $1 ` user := &domain.User{} err := r.db.QueryRowContext(ctx, query, email).Scan( &user.ID, &user.Email, &user.Name, &user.Active, &user.CreatedAt, &user.UpdatedAt, ) if err == sql.ErrNoRows { return nil, domain.ErrUserNotFound } return user, err } func (r *PostgresRepository) Update(ctx context.Context, user *domain.User) error { query := ` UPDATE users.users SET email = $2, name = $3, active = $4, updated_at = $5 WHERE id = $1 ` _, err := r.db.ExecContext(ctx, query, user.ID, user.Email, user.Name, user.Active, user.UpdatedAt) return err } func (r *PostgresRepository) Delete(ctx context.Context, id domain.UserID) error { query := `DELETE FROM users.users WHERE id = $1` _, err := r.db.ExecContext(ctx, query, id) return err } // internal/modules/user/module.go package user import ( "database/sql" "app/internal/modules/user/application" "app/internal/modules/user/infrastructure" ) type Module struct { Service *application.Service } func NewModule(db *sql.DB) *Module { repo := infrastructure.NewPostgresRepository(db) service := application.NewService(repo) return &Module{ Service: service, } } Module 2: Product Module // internal/modules/product/domain/product.go package domain import ( "context" "errors" "time" ) type ProductID string type Product struct { ID ProductID Name string Description string Price float64 Stock int CreatedAt time.Time UpdatedAt time.Time } var ( ErrProductNotFound = errors.New("product not found") ErrInsufficientStock = errors.New("insufficient stock") ErrInvalidPrice = errors.New("invalid price") ) type Repository interface { Create(ctx context.Context, product *Product) error GetByID(ctx context.Context, id ProductID) (*Product, error) Update(ctx context.Context, product *Product) error ReserveStock(ctx context.Context, id ProductID, quantity int) error ReleaseStock(ctx context.Context, id ProductID, quantity int) error } // internal/modules/product/application/service.go package application import ( "context" "fmt" "app/internal/modules/product/domain" ) type Service struct { repo domain.Repository } func NewService(repo domain.Repository) *Service { return &Service{repo: repo} } func (s *Service) CreateProduct(ctx context.Context, name, description string, price float64, stock int) (*domain.Product, error) { if price <= 0 { return nil, domain.ErrInvalidPrice } product := &domain.Product{ ID: domain.ProductID(generateID()), Name: name, Description: description, Price: price, Stock: stock, CreatedAt: time.Now(), UpdatedAt: time.Now(), } if err := s.repo.Create(ctx, product); err != nil { return nil, fmt.Errorf("failed to create product: %w", err) } return product, nil } func (s *Service) GetProduct(ctx context.Context, id domain.ProductID) (*domain.Product, error) { return s.repo.GetByID(ctx, id) } func (s *Service) CheckStock(ctx context.Context, id domain.ProductID, quantity int) (bool, error) { product, err := s.repo.GetByID(ctx, id) if err != nil { return false, err } return product.Stock >= quantity, nil } func (s *Service) ReserveStock(ctx context.Context, id domain.ProductID, quantity int) error { available, err := s.CheckStock(ctx, id, quantity) if err != nil { return err } if !available { return domain.ErrInsufficientStock } return s.repo.ReserveStock(ctx, id, quantity) } func (s *Service) ConfirmReservation(ctx context.Context, id domain.ProductID, quantity int) error { // In a real implementation, this would mark the reservation as confirmed return nil } func generateID() string { return fmt.Sprintf("product_%d", time.Now().UnixNano()) } // internal/modules/product/infrastructure/postgres_repository.go package infrastructure import ( "context" "database/sql" "app/internal/modules/product/domain" ) type PostgresRepository struct { db *sql.DB } func NewPostgresRepository(db *sql.DB) *PostgresRepository { return &PostgresRepository{db: db} } func (r *PostgresRepository) Create(ctx context.Context, product *domain.Product) error { query := ` INSERT INTO products.products (id, name, description, price, stock, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7) ` _, err := r.db.ExecContext(ctx, query, product.ID, product.Name, product.Description, product.Price, product.Stock, product.CreatedAt, product.UpdatedAt) return err } func (r *PostgresRepository) GetByID(ctx context.Context, id domain.ProductID) (*domain.Product, error) { query := ` SELECT id, name, description, price, stock, created_at, updated_at FROM products.products WHERE id = $1 ` product := &domain.Product{} err := r.db.QueryRowContext(ctx, query, id).Scan( &product.ID, &product.Name, &product.Description, &product.Price, &product.Stock, &product.CreatedAt, &product.UpdatedAt, ) if err == sql.ErrNoRows { return nil, domain.ErrProductNotFound } return product, err } func (r *PostgresRepository) Update(ctx context.Context, product *domain.Product) error { query := ` UPDATE products.products SET name = $2, description = $3, price = $4, stock = $5, updated_at = $6 WHERE id = $1 ` _, err := r.db.ExecContext(ctx, query, product.ID, product.Name, product.Description, product.Price, product.Stock, product.UpdatedAt) return err } func (r *PostgresRepository) ReserveStock(ctx context.Context, id domain.ProductID, quantity int) error { query := ` UPDATE products.products SET stock = stock - $2 WHERE id = $1 AND stock >= $2 ` result, err := r.db.ExecContext(ctx, query, id, quantity) if err != nil { return err } rows, err := result.RowsAffected() if err != nil { return err } if rows == 0 { return domain.ErrInsufficientStock } return nil } func (r *PostgresRepository) ReleaseStock(ctx context.Context, id domain.ProductID, quantity int) error { query := ` UPDATE products.products SET stock = stock + $2 WHERE id = $1 ` _, err := r.db.ExecContext(ctx, query, id, quantity) return err } // internal/modules/product/module.go package product import ( "database/sql" "app/internal/modules/product/application" "app/internal/modules/product/infrastructure" ) type Module struct { Service *application.Service } func NewModule(db *sql.DB) *Module { repo := infrastructure.NewPostgresRepository(db) service := application.NewService(repo) return &Module{ Service: service, } } Module 3: Order Module (Coordinates Other Modules) // internal/modules/order/domain/order.go package domain import ( "context" "errors" "time" "app/internal/modules/user/domain" "app/internal/modules/product/domain" ) type OrderID string type OrderStatus string const ( OrderStatusPending OrderStatus = "pending" OrderStatusConfirmed OrderStatus = "confirmed" OrderStatusCancelled OrderStatus = "cancelled" ) type OrderItem struct { ProductID domain.ProductID Quantity int Price float64 } type Order struct { ID OrderID UserID domain.UserID Items []OrderItem Total float64 Status OrderStatus CreatedAt time.Time UpdatedAt time.Time } var ( ErrOrderNotFound = errors.New("order not found") ErrInvalidOrder = errors.New("invalid order") ) type Repository interface { Create(ctx context.Context, order *Order) error GetByID(ctx context.Context, id OrderID) (*Order, error) Update(ctx context.Context, order *Order) error } // internal/modules/order/application/service.go package application import ( "context" "fmt" "time" orderdomain "app/internal/modules/order/domain" productapp "app/internal/modules/product/application" userapp "app/internal/modules/user/application" ) // Service coordinates between modules type Service struct { repo orderdomain.Repository userService *userapp.Service productService *productapp.Service } func NewService( repo orderdomain.Repository, userService *userapp.Service, productService *productapp.Service, ) *Service { return &Service{ repo: repo, userService: userService, productService: productService, } } func (s *Service) CreateOrder(ctx context.Context, userID domain.UserID, items []orderdomain.OrderItem) (*orderdomain.Order, error) { // Validate user through User module valid, err := s.userService.ValidateUser(ctx, userID) if err != nil { return nil, fmt.Errorf("failed to validate user: %w", err) } if !valid { return nil, fmt.Errorf("user is not active") } // Calculate total and validate products var total float64 for i, item := range items { product, err := s.productService.GetProduct(ctx, item.ProductID) if err != nil { return nil, fmt.Errorf("failed to get product: %w", err) } // Check stock availability available, err := s.productService.CheckStock(ctx, item.ProductID, item.Quantity) if err != nil { return nil, fmt.Errorf("failed to check stock: %w", err) } if !available { return nil, fmt.Errorf("insufficient stock for product %s", item.ProductID) } items[i].Price = product.Price total += product.Price * float64(item.Quantity) } // Reserve stock for all items for _, item := range items { if err := s.productService.ReserveStock(ctx, item.ProductID, item.Quantity); err != nil { // Rollback reservations on failure return nil, fmt.Errorf("failed to reserve stock: %w", err) } } order := &orderdomain.Order{ ID: orderdomain.OrderID(generateID()), UserID: userID, Items: items, Total: total, Status: orderdomain.OrderStatusPending, CreatedAt: time.Now(), UpdatedAt: time.Now(), } if err := s.repo.Create(ctx, order); err != nil { return nil, fmt.Errorf("failed to create order: %w", err) } return order, nil } func (s *Service) GetOrder(ctx context.Context, id orderdomain.OrderID) (*orderdomain.Order, error) { return s.repo.GetByID(ctx, id) } func (s *Service) ConfirmOrder(ctx context.Context, id orderdomain.OrderID) error { order, err := s.repo.GetByID(ctx, id) if err != nil { return err } // Confirm stock reservations for _, item := range order.Items { if err := s.productService.ConfirmReservation(ctx, item.ProductID, item.Quantity); err != nil { return fmt.Errorf("failed to confirm reservation: %w", err) } } order.Status = orderdomain.OrderStatusConfirmed order.UpdatedAt = time.Now() return s.repo.Update(ctx, order) } func generateID() string { return fmt.Sprintf("order_%d", time.Now().UnixNano()) } // internal/modules/order/module.go package order import ( "database/sql" "app/internal/modules/order/application" "app/internal/modules/order/infrastructure" productapp "app/internal/modules/product/application" userapp "app/internal/modules/user/application" ) type Module struct { Service *application.Service } func NewModule(db *sql.DB, userService *userapp.Service, productService *productapp.Service) *Module { repo := infrastructure.NewPostgresRepository(db) service := application.NewService(repo, userService, productService) return &Module{ Service: service, } } Main Application // cmd/app/main.go package main import ( "database/sql" "log" "net/http" _ "github.com/lib/pq" "app/internal/modules/user" "app/internal/modules/product" "app/internal/modules/order" ) func main() { // Initialize database db, err := sql.Open("postgres", "postgres://user:pass@localhost/modular_monolith?sslmode=disable") if err != nil { log.Fatal(err) } defer db.Close() // Initialize modules userModule := user.NewModule(db) productModule := product.NewModule(db) orderModule := order.NewModule(db, userModule.Service, productModule.Service) // Setup HTTP routes mux := http.NewServeMux() // User endpoints mux.HandleFunc("POST /users", func(w http.ResponseWriter, r *http.Request) { // Handle user creation }) // Product endpoints mux.HandleFunc("POST /products", func(w http.ResponseWriter, r *http.Request) { // Handle product creation }) // Order endpoints mux.HandleFunc("POST /orders", func(w http.ResponseWriter, r *http.Request) { // Handle order creation using orderModule.Service }) log.Println("Server starting on :8080") if err := http.ListenAndServe(":8080", mux); err != nil { log.Fatal(err) } } Best Practices Module Boundaries: Keep modules independent with clear interfaces Shared Database: Use schemas or table prefixes to separate module data Module APIs: Define explicit APIs for inter-module communication Dependency Direction: Modules should depend on interfaces, not implementations Event-Driven Communication: Use events for async inter-module communication Transaction Management: Handle cross-module transactions carefully Testing: Test modules independently with mocked dependencies Documentation: Document module APIs and boundaries clearly Common Pitfalls Shared Models: Sharing domain models between modules creates tight coupling Direct Database Access: Modules accessing other modules’ database tables Circular Dependencies: Modules depending on each other directly Anemic Modules: Modules with no business logic, just CRUD operations God Modules: Modules that know too much about other modules Ignoring Boundaries: Calling internal implementations instead of module APIs Synchronous Coupling: Over-reliance on synchronous inter-module calls When to Use Modular Monolith Use When: ...

    January 19, 2025 · 13 min · Rafiul Alam

    Visual Guide to Distributed Systems Patterns

    Introduction Building robust distributed systems requires understanding fundamental patterns that solve common challenges like consensus, fault tolerance, request distribution, and asynchronous communication. This comprehensive guide uses visual diagrams to illustrate how these patterns work, making complex distributed systems concepts easier to understand and implement. We’ll explore: Raft Consensus Algorithm: How distributed systems agree on shared state Circuit Breaker Pattern: Preventing cascading failures in microservices Load Balancing Algorithms: Distributing traffic efficiently across servers Message Queue Patterns: Asynchronous communication strategies Part 1: Raft Consensus Algorithm The Raft consensus algorithm ensures that a cluster of servers maintains a consistent, replicated log even in the face of failures. It’s designed to be more understandable than Paxos while providing the same guarantees. ...

    January 17, 2025 · 24 min · Rafiul Alam

    Event Sourcing in Go: Building Audit-Trail Applications

    Go Architecture Patterns Series: ← Previous: CQRS Pattern | Series Overview | Next: Saga Pattern → What is Event Sourcing? Event Sourcing is an architectural pattern where state changes are stored as a sequence of immutable events rather than updating records in place. The current state is derived by replaying all events from the beginning. Key Principles: Event Store: All state changes stored as events Immutable Events: Events are never modified or deleted Event Replay: Current state reconstructed by replaying events Complete Audit Trail: Every change is recorded with full context Temporal Queries: Query state at any point in time Event Versioning: Events can evolve while maintaining history Architecture Overview %%{init: {'theme':'dark', 'themeVariables': {'primaryTextColor':'#e5e7eb','secondaryTextColor':'#e5e7eb','tertiaryTextColor':'#e5e7eb','textColor':'#e5e7eb','nodeTextColor':'#e5e7eb','edgeLabelText':'#e5e7eb','clusterTextColor':'#e5e7eb','actorTextColor':'#e5e7eb'}}}%% graph TB subgraph "Command Side" Command[Command] Aggregate[Aggregate Root] EventStore[(Event Store)] end subgraph "Event Processing" EventBus[Event Bus] Projector[Event Projectors] end subgraph "Query Side" ReadModel1[(Current State View)] ReadModel2[(Analytics View)] ReadModel3[(Audit Log View)] end subgraph "Time Travel" Snapshot[(Snapshots)] Replay[Event Replay] end Command --> Aggregate Aggregate -->|Load Events| EventStore Aggregate -->|Append Events| EventStore EventStore -->|Publish| EventBus EventBus --> Projector Projector --> ReadModel1 Projector --> ReadModel2 Projector --> ReadModel3 EventStore --> Snapshot Snapshot --> Replay Replay --> Aggregate style Aggregate fill:#78350f,color:#fff style EventStore fill:#1e3a5f,color:#fff style Projector fill:#134e4a,color:#fff style ReadModel1 fill:#4c1d95,color:#fff Event Sourcing Flow %%{init: {'theme':'dark', 'themeVariables': {'primaryTextColor':'#e5e7eb','secondaryTextColor':'#e5e7eb','tertiaryTextColor':'#e5e7eb','textColor':'#e5e7eb','nodeTextColor':'#e5e7eb','edgeLabelText':'#e5e7eb','clusterTextColor':'#e5e7eb','actorTextColor':'#e5e7eb'}}}%% sequenceDiagram participant Client participant CommandHandler participant Aggregate participant EventStore participant EventBus participant Projection Client->>CommandHandler: Execute Command CommandHandler->>EventStore: Load Events for Aggregate EventStore-->>CommandHandler: Event Stream CommandHandler->>Aggregate: Replay Events Aggregate->>Aggregate: Rebuild State CommandHandler->>Aggregate: Execute Command Aggregate->>Aggregate: Validate Business Rules Aggregate->>Aggregate: Generate Events CommandHandler->>EventStore: Append New Events EventStore-->>CommandHandler: Events Persisted EventStore->>EventBus: Publish Events EventBus->>Projection: Project Events Projection->>Projection: Update Read Model CommandHandler-->>Client: Command Accepted Real-World Use Cases Banking Systems: Complete transaction history and audit trails E-commerce: Order lifecycle tracking and returns processing Healthcare: Patient medical history and treatment records Trading Systems: Trade execution history and reconciliation Gaming: Player action history and replay functionality Workflow Systems: Process execution history and debugging Event Sourcing Implementation Project Structure event-sourcing-app/ ├── cmd/ │ └── api/ │ └── main.go ├── internal/ │ ├── domain/ │ │ ├── account.go │ │ └── events.go │ ├── eventstore/ │ │ ├── store.go │ │ ├── postgres_store.go │ │ └── snapshot.go │ ├── commands/ │ │ └── handlers.go │ ├── projections/ │ │ └── account_projection.go │ └── queries/ │ └── handlers.go └── go.mod Domain Events // internal/domain/events.go package domain import ( "encoding/json" "time" ) // Event represents a domain event type Event interface { EventType() string AggregateID() string EventVersion() int Timestamp() time.Time } // BaseEvent provides common event fields type BaseEvent struct { Type string `json:"type"` AggrID string `json:"aggregate_id"` Version int `json:"version"` OccurredAt time.Time `json:"occurred_at"` Metadata map[string]string `json:"metadata"` } func (e BaseEvent) EventType() string { return e.Type } func (e BaseEvent) AggregateID() string { return e.AggrID } func (e BaseEvent) EventVersion() int { return e.Version } func (e BaseEvent) Timestamp() time.Time { return e.OccurredAt } // Account Events type AccountCreatedEvent struct { BaseEvent AccountID string `json:"account_id"` Owner string `json:"owner"` Currency string `json:"currency"` } type MoneyDepositedEvent struct { BaseEvent AccountID string `json:"account_id"` Amount float64 `json:"amount"` Balance float64 `json:"balance"` } type MoneyWithdrawnEvent struct { BaseEvent AccountID string `json:"account_id"` Amount float64 `json:"amount"` Balance float64 `json:"balance"` } type AccountClosedEvent struct { BaseEvent AccountID string `json:"account_id"` Reason string `json:"reason"` } // EventEnvelope wraps events for storage type EventEnvelope struct { EventID string `json:"event_id"` EventType string `json:"event_type"` AggregateID string `json:"aggregate_id"` Version int `json:"version"` Data json.RawMessage `json:"data"` Metadata map[string]string `json:"metadata"` CreatedAt time.Time `json:"created_at"` } // Serialize serializes event to envelope func SerializeEvent(event Event) (*EventEnvelope, error) { data, err := json.Marshal(event) if err != nil { return nil, err } return &EventEnvelope{ EventID: generateEventID(), EventType: event.EventType(), AggregateID: event.AggregateID(), Version: event.EventVersion(), Data: data, CreatedAt: event.Timestamp(), }, nil } // DeserializeEvent deserializes envelope to event func DeserializeEvent(envelope *EventEnvelope) (Event, error) { var event Event switch envelope.EventType { case "AccountCreated": event = &AccountCreatedEvent{} case "MoneyDeposited": event = &MoneyDepositedEvent{} case "MoneyWithdrawn": event = &MoneyWithdrawnEvent{} case "AccountClosed": event = &AccountClosedEvent{} default: return nil, fmt.Errorf("unknown event type: %s", envelope.EventType) } if err := json.Unmarshal(envelope.Data, event); err != nil { return nil, err } return event, nil } func generateEventID() string { return fmt.Sprintf("evt_%d", time.Now().UnixNano()) } Aggregate Root // internal/domain/account.go package domain import ( "errors" "fmt" ) var ( ErrInsufficientFunds = errors.New("insufficient funds") ErrAccountClosed = errors.New("account is closed") ErrInvalidAmount = errors.New("invalid amount") ) // Account is an aggregate root that uses event sourcing type Account struct { id string owner string currency string balance float64 isClosed bool version int uncommittedEvents []Event } // NewAccount creates a new account func NewAccount(id, owner, currency string) (*Account, error) { if id == "" || owner == "" || currency == "" { return nil, errors.New("invalid account parameters") } account := &Account{ uncommittedEvents: make([]Event, 0), } // Raise domain event event := AccountCreatedEvent{ BaseEvent: BaseEvent{ Type: "AccountCreated", AggrID: id, Version: 1, OccurredAt: time.Now(), }, AccountID: id, Owner: owner, Currency: currency, } account.raiseEvent(&event) return account, nil } // LoadFromHistory rebuilds account state from events func LoadFromHistory(events []Event) (*Account, error) { if len(events) == 0 { return nil, errors.New("no events to load") } account := &Account{ uncommittedEvents: make([]Event, 0), } for _, event := range events { if err := account.apply(event); err != nil { return nil, err } account.version = event.EventVersion() } return account, nil } // Deposit adds money to account func (a *Account) Deposit(amount float64) error { if a.isClosed { return ErrAccountClosed } if amount <= 0 { return ErrInvalidAmount } newBalance := a.balance + amount event := MoneyDepositedEvent{ BaseEvent: BaseEvent{ Type: "MoneyDeposited", AggrID: a.id, Version: a.version + 1, OccurredAt: time.Now(), }, AccountID: a.id, Amount: amount, Balance: newBalance, } a.raiseEvent(&event) return nil } // Withdraw removes money from account func (a *Account) Withdraw(amount float64) error { if a.isClosed { return ErrAccountClosed } if amount <= 0 { return ErrInvalidAmount } if a.balance < amount { return ErrInsufficientFunds } newBalance := a.balance - amount event := MoneyWithdrawnEvent{ BaseEvent: BaseEvent{ Type: "MoneyWithdrawn", AggrID: a.id, Version: a.version + 1, OccurredAt: time.Now(), }, AccountID: a.id, Amount: amount, Balance: newBalance, } a.raiseEvent(&event) return nil } // Close closes the account func (a *Account) Close(reason string) error { if a.isClosed { return ErrAccountClosed } event := AccountClosedEvent{ BaseEvent: BaseEvent{ Type: "AccountClosed", AggrID: a.id, Version: a.version + 1, OccurredAt: time.Now(), }, AccountID: a.id, Reason: reason, } a.raiseEvent(&event) return nil } // GetUncommittedEvents returns uncommitted events func (a *Account) GetUncommittedEvents() []Event { return a.uncommittedEvents } // MarkEventsAsCommitted clears uncommitted events func (a *Account) MarkEventsAsCommitted() { a.uncommittedEvents = make([]Event, 0) } // raiseEvent adds event to uncommitted and applies it func (a *Account) raiseEvent(event Event) { a.uncommittedEvents = append(a.uncommittedEvents, event) a.apply(event) } // apply applies an event to the aggregate state func (a *Account) apply(event Event) error { switch e := event.(type) { case *AccountCreatedEvent: a.id = e.AccountID a.owner = e.Owner a.currency = e.Currency a.balance = 0 a.isClosed = false case *MoneyDepositedEvent: a.balance = e.Balance case *MoneyWithdrawnEvent: a.balance = e.Balance case *AccountClosedEvent: a.isClosed = true default: return fmt.Errorf("unknown event type: %T", event) } return nil } // Getters func (a *Account) ID() string { return a.id } func (a *Account) Balance() float64 { return a.balance } func (a *Account) Version() int { return a.version } func (a *Account) IsClosed() bool { return a.isClosed } Event Store // internal/eventstore/store.go package eventstore import ( "context" "app/internal/domain" ) // EventStore interface type EventStore interface { SaveEvents(ctx context.Context, aggregateID string, events []domain.Event, expectedVersion int) error GetEvents(ctx context.Context, aggregateID string) ([]domain.Event, error) GetEventsSince(ctx context.Context, aggregateID string, version int) ([]domain.Event, error) GetAllEvents(ctx context.Context) ([]domain.Event, error) } // internal/eventstore/postgres_store.go package eventstore import ( "context" "database/sql" "fmt" "app/internal/domain" ) type PostgresEventStore struct { db *sql.DB } func NewPostgresEventStore(db *sql.DB) *PostgresEventStore { return &PostgresEventStore{db: db} } // SaveEvents appends events to the event store with optimistic concurrency func (s *PostgresEventStore) SaveEvents(ctx context.Context, aggregateID string, events []domain.Event, expectedVersion int) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() // Check current version for optimistic locking var currentVersion int err = tx.QueryRowContext(ctx, "SELECT COALESCE(MAX(version), 0) FROM events WHERE aggregate_id = $1", aggregateID, ).Scan(&currentVersion) if err != nil && err != sql.ErrNoRows { return err } if currentVersion != expectedVersion { return fmt.Errorf("concurrency conflict: expected version %d, got %d", expectedVersion, currentVersion) } // Insert events query := ` INSERT INTO events (event_id, event_type, aggregate_id, version, data, metadata, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7) ` for _, event := range events { envelope, err := domain.SerializeEvent(event) if err != nil { return err } _, err = tx.ExecContext(ctx, query, envelope.EventID, envelope.EventType, envelope.AggregateID, envelope.Version, envelope.Data, envelope.Metadata, envelope.CreatedAt, ) if err != nil { return err } } return tx.Commit() } // GetEvents retrieves all events for an aggregate func (s *PostgresEventStore) GetEvents(ctx context.Context, aggregateID string) ([]domain.Event, error) { query := ` SELECT event_id, event_type, aggregate_id, version, data, metadata, created_at FROM events WHERE aggregate_id = $1 ORDER BY version ASC ` rows, err := s.db.QueryContext(ctx, query, aggregateID) if err != nil { return nil, err } defer rows.Close() var events []domain.Event for rows.Next() { envelope := &domain.EventEnvelope{} err := rows.Scan( &envelope.EventID, &envelope.EventType, &envelope.AggregateID, &envelope.Version, &envelope.Data, &envelope.Metadata, &envelope.CreatedAt, ) if err != nil { return nil, err } event, err := domain.DeserializeEvent(envelope) if err != nil { return nil, err } events = append(events, event) } return events, nil } // GetEventsSince retrieves events after a specific version func (s *PostgresEventStore) GetEventsSince(ctx context.Context, aggregateID string, version int) ([]domain.Event, error) { query := ` SELECT event_id, event_type, aggregate_id, version, data, metadata, created_at FROM events WHERE aggregate_id = $1 AND version > $2 ORDER BY version ASC ` rows, err := s.db.QueryContext(ctx, query, aggregateID, version) if err != nil { return nil, err } defer rows.Close() var events []domain.Event for rows.Next() { envelope := &domain.EventEnvelope{} err := rows.Scan( &envelope.EventID, &envelope.EventType, &envelope.AggregateID, &envelope.Version, &envelope.Data, &envelope.Metadata, &envelope.CreatedAt, ) if err != nil { return nil, err } event, err := domain.DeserializeEvent(envelope) if err != nil { return nil, err } events = append(events, event) } return events, nil } // GetAllEvents retrieves all events from the store func (s *PostgresEventStore) GetAllEvents(ctx context.Context) ([]domain.Event, error) { query := ` SELECT event_id, event_type, aggregate_id, version, data, metadata, created_at FROM events ORDER BY created_at ASC ` rows, err := s.db.QueryContext(ctx, query) if err != nil { return nil, err } defer rows.Close() var events []domain.Event for rows.Next() { envelope := &domain.EventEnvelope{} err := rows.Scan( &envelope.EventID, &envelope.EventType, &envelope.AggregateID, &envelope.Version, &envelope.Data, &envelope.Metadata, &envelope.CreatedAt, ) if err != nil { return nil, err } event, err := domain.DeserializeEvent(envelope) if err != nil { return nil, err } events = append(events, event) } return events, nil } Snapshots for Performance // internal/eventstore/snapshot.go package eventstore import ( "context" "database/sql" "encoding/json" "time" ) // Snapshot represents a point-in-time state type Snapshot struct { AggregateID string Version int State json.RawMessage CreatedAt time.Time } type SnapshotStore struct { db *sql.DB } func NewSnapshotStore(db *sql.DB) *SnapshotStore { return &SnapshotStore{db: db} } // SaveSnapshot saves a snapshot func (s *SnapshotStore) SaveSnapshot(ctx context.Context, snapshot *Snapshot) error { query := ` INSERT INTO snapshots (aggregate_id, version, state, created_at) VALUES ($1, $2, $3, $4) ON CONFLICT (aggregate_id) DO UPDATE SET version = $2, state = $3, created_at = $4 ` _, err := s.db.ExecContext(ctx, query, snapshot.AggregateID, snapshot.Version, snapshot.State, snapshot.CreatedAt, ) return err } // GetSnapshot retrieves the latest snapshot func (s *SnapshotStore) GetSnapshot(ctx context.Context, aggregateID string) (*Snapshot, error) { query := ` SELECT aggregate_id, version, state, created_at FROM snapshots WHERE aggregate_id = $1 ` snapshot := &Snapshot{} err := s.db.QueryRowContext(ctx, query, aggregateID).Scan( &snapshot.AggregateID, &snapshot.Version, &snapshot.State, &snapshot.CreatedAt, ) if err == sql.ErrNoRows { return nil, nil } return snapshot, err } Command Handlers // internal/commands/handlers.go package commands import ( "context" "fmt" "app/internal/domain" "app/internal/eventstore" ) // CreateAccountCommand type CreateAccountCommand struct { AccountID string Owner string Currency string } type CreateAccountHandler struct { eventStore eventstore.EventStore } func NewCreateAccountHandler(store eventstore.EventStore) *CreateAccountHandler { return &CreateAccountHandler{eventStore: store} } func (h *CreateAccountHandler) Handle(ctx context.Context, cmd *CreateAccountCommand) error { // Create new aggregate account, err := domain.NewAccount(cmd.AccountID, cmd.Owner, cmd.Currency) if err != nil { return err } // Save events events := account.GetUncommittedEvents() if err := h.eventStore.SaveEvents(ctx, cmd.AccountID, events, 0); err != nil { return fmt.Errorf("failed to save events: %w", err) } account.MarkEventsAsCommitted() return nil } // DepositMoneyCommand type DepositMoneyCommand struct { AccountID string Amount float64 } type DepositMoneyHandler struct { eventStore eventstore.EventStore } func NewDepositMoneyHandler(store eventstore.EventStore) *DepositMoneyHandler { return &DepositMoneyHandler{eventStore: store} } func (h *DepositMoneyHandler) Handle(ctx context.Context, cmd *DepositMoneyCommand) error { // Load aggregate from events events, err := h.eventStore.GetEvents(ctx, cmd.AccountID) if err != nil { return err } account, err := domain.LoadFromHistory(events) if err != nil { return err } // Execute command if err := account.Deposit(cmd.Amount); err != nil { return err } // Save new events newEvents := account.GetUncommittedEvents() if err := h.eventStore.SaveEvents(ctx, cmd.AccountID, newEvents, account.Version()); err != nil { return fmt.Errorf("failed to save events: %w", err) } account.MarkEventsAsCommitted() return nil } // WithdrawMoneyCommand type WithdrawMoneyCommand struct { AccountID string Amount float64 } type WithdrawMoneyHandler struct { eventStore eventstore.EventStore } func NewWithdrawMoneyHandler(store eventstore.EventStore) *WithdrawMoneyHandler { return &WithdrawMoneyHandler{eventStore: store} } func (h *WithdrawMoneyHandler) Handle(ctx context.Context, cmd *WithdrawMoneyCommand) error { events, err := h.eventStore.GetEvents(ctx, cmd.AccountID) if err != nil { return err } account, err := domain.LoadFromHistory(events) if err != nil { return err } if err := account.Withdraw(cmd.Amount); err != nil { return err } newEvents := account.GetUncommittedEvents() if err := h.eventStore.SaveEvents(ctx, cmd.AccountID, newEvents, account.Version()); err != nil { return fmt.Errorf("failed to save events: %w", err) } account.MarkEventsAsCommitted() return nil } Projections // internal/projections/account_projection.go package projections import ( "context" "database/sql" "app/internal/domain" ) // AccountReadModel represents the current state type AccountReadModel struct { ID string Owner string Currency string Balance float64 IsClosed bool TotalDeposits float64 TotalWithdrawals float64 TransactionCount int } type AccountProjection struct { db *sql.DB } func NewAccountProjection(db *sql.DB) *AccountProjection { return &AccountProjection{db: db} } // Project processes events and updates read model func (p *AccountProjection) Project(ctx context.Context, event domain.Event) error { switch e := event.(type) { case *domain.AccountCreatedEvent: return p.handleAccountCreated(ctx, e) case *domain.MoneyDepositedEvent: return p.handleMoneyDeposited(ctx, e) case *domain.MoneyWithdrawnEvent: return p.handleMoneyWithdrawn(ctx, e) case *domain.AccountClosedEvent: return p.handleAccountClosed(ctx, e) } return nil } func (p *AccountProjection) handleAccountCreated(ctx context.Context, event *domain.AccountCreatedEvent) error { query := ` INSERT INTO account_read_model (id, owner, currency, balance, is_closed, total_deposits, total_withdrawals, transaction_count) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ` _, err := p.db.ExecContext(ctx, query, event.AccountID, event.Owner, event.Currency, 0, false, 0, 0, 0) return err } func (p *AccountProjection) handleMoneyDeposited(ctx context.Context, event *domain.MoneyDepositedEvent) error { query := ` UPDATE account_read_model SET balance = $2, total_deposits = total_deposits + $3, transaction_count = transaction_count + 1 WHERE id = $1 ` _, err := p.db.ExecContext(ctx, query, event.AccountID, event.Balance, event.Amount) return err } func (p *AccountProjection) handleMoneyWithdrawn(ctx context.Context, event *domain.MoneyWithdrawnEvent) error { query := ` UPDATE account_read_model SET balance = $2, total_withdrawals = total_withdrawals + $3, transaction_count = transaction_count + 1 WHERE id = $1 ` _, err := p.db.ExecContext(ctx, query, event.AccountID, event.Balance, event.Amount) return err } func (p *AccountProjection) handleAccountClosed(ctx context.Context, event *domain.AccountClosedEvent) error { query := `UPDATE account_read_model SET is_closed = true WHERE id = $1` _, err := p.db.ExecContext(ctx, query, event.AccountID) return err } // RebuildProjections rebuilds all projections from events func (p *AccountProjection) RebuildProjections(ctx context.Context, eventStore eventstore.EventStore) error { // Clear existing projections if _, err := p.db.ExecContext(ctx, "TRUNCATE account_read_model"); err != nil { return err } // Replay all events events, err := eventStore.GetAllEvents(ctx) if err != nil { return err } for _, event := range events { if err := p.Project(ctx, event); err != nil { return err } } return nil } Main Application // cmd/api/main.go package main import ( "context" "database/sql" "encoding/json" "log" "net/http" "github.com/gorilla/mux" _ "github.com/lib/pq" "app/internal/commands" "app/internal/eventstore" "app/internal/projections" ) func main() { db, err := sql.Open("postgres", "postgres://user:pass@localhost/eventsourcing?sslmode=disable") if err != nil { log.Fatal(err) } defer db.Close() // Initialize stores eventStore := eventstore.NewPostgresEventStore(db) projection := projections.NewAccountProjection(db) // Initialize command handlers createAccountHandler := commands.NewCreateAccountHandler(eventStore) depositHandler := commands.NewDepositMoneyHandler(eventStore) withdrawHandler := commands.NewWithdrawMoneyHandler(eventStore) router := mux.NewRouter() // Command endpoints router.HandleFunc("/accounts", func(w http.ResponseWriter, r *http.Request) { var cmd commands.CreateAccountCommand if err := json.NewDecoder(r.Body).Decode(&cmd); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if err := createAccountHandler.Handle(r.Context(), &cmd); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } w.WriteHeader(http.StatusCreated) }).Methods("POST") router.HandleFunc("/accounts/{id}/deposit", func(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) var req struct { Amount float64 `json:"amount"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } cmd := commands.DepositMoneyCommand{ AccountID: vars["id"], Amount: req.Amount, } if err := depositHandler.Handle(r.Context(), &cmd); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } w.WriteHeader(http.StatusOK) }).Methods("POST") // Event replay endpoint router.HandleFunc("/admin/rebuild-projections", func(w http.ResponseWriter, r *http.Request) { if err := projection.RebuildProjections(r.Context(), eventStore); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) w.Write([]byte("Projections rebuilt successfully")) }).Methods("POST") log.Println("Server starting on :8080") log.Fatal(http.ListenAndServe(":8080", router)) } Best Practices Immutable Events: Never modify or delete events Event Versioning: Version events for schema evolution Snapshots: Use snapshots to optimize replay performance Idempotency: Ensure event handlers are idempotent Event Schema: Keep events small and focused Testing: Test event replay and projections Monitoring: Track event store size and replay performance Documentation: Document all event types and their purpose Common Pitfalls Mutable Events: Modifying or deleting events Missing Events: Not capturing all state changes Large Events: Storing too much data in events No Snapshots: Performance degradation from replaying all events Tight Coupling: Events containing implementation details No Versioning: Breaking changes to event schemas Synchronous Projections: Slowing down command processing When to Use Event Sourcing Use When: ...

    January 16, 2025 · 15 min · Rafiul Alam

    Layered Architecture in Go: Building Maintainable Applications

    Go Architecture Patterns Series: Series Overview | Next: Clean Architecture → What is Layered Architecture? Layered Architecture is one of the most common and fundamental architectural patterns in software development. It organizes code into horizontal layers, where each layer has a specific responsibility and only communicates with adjacent layers. Key Principles: Separation of Concerns: Each layer handles a specific aspect of the application Layer Independence: Layers are loosely coupled and can be changed independently Unidirectional Dependencies: Dependencies flow in one direction (top-down) Clear Boundaries: Well-defined interfaces between layers Testability: Each layer can be tested in isolation Architecture Overview %%{init: {'theme':'dark', 'themeVariables': {'primaryTextColor':'#e5e7eb','secondaryTextColor':'#e5e7eb','tertiaryTextColor':'#e5e7eb','textColor':'#e5e7eb','nodeTextColor':'#e5e7eb','edgeLabelText':'#e5e7eb','clusterTextColor':'#e5e7eb','actorTextColor':'#e5e7eb'}}}%% graph TD A[Presentation Layer] --> B[Business Logic Layer] B --> C[Data Access Layer] C --> D[(Database)] style A fill:#1e3a5f,color:#fff style B fill:#78350f,color:#fff style C fill:#134e4a,color:#fff style D fill:#4c1d95,color:#fff Traditional 3-Tier Layered Architecture %%{init: {'theme':'dark', 'themeVariables': {'primaryTextColor':'#e5e7eb','secondaryTextColor':'#e5e7eb','tertiaryTextColor':'#e5e7eb','textColor':'#e5e7eb','nodeTextColor':'#e5e7eb','edgeLabelText':'#e5e7eb','clusterTextColor':'#e5e7eb','actorTextColor':'#e5e7eb'}}}%% graph LR subgraph "Presentation Layer" A1[HTTP Handlers] A2[Templates/Views] A3[Request/Response DTOs] end subgraph "Business Logic Layer" B1[Services] B2[Business Rules] B3[Domain Models] end subgraph "Data Access Layer" C1[Repositories] C2[Database Access] C3[Data Models] end A1 --> B1 A2 --> B1 B1 --> C1 C1 --> C2 style A1 fill:#1e3a5f,color:#fff style B1 fill:#78350f,color:#fff style C1 fill:#134e4a,color:#fff Real-World Use Cases Web Applications: RESTful APIs and web services Enterprise Applications: Business management systems CRUD Applications: Standard create-read-update-delete operations Monolithic Applications: Traditional single-deployment applications Internal Tools: Admin panels and dashboards Legacy System Modernization: Refactoring existing codebases Basic Layered Architecture Implementation Project Structure ├── cmd/ │ └── api/ │ └── main.go ├── internal/ │ ├── handlers/ # Presentation Layer │ │ ├── user_handler.go │ │ └── product_handler.go │ ├── services/ # Business Logic Layer │ │ ├── user_service.go │ │ └── product_service.go │ ├── repositories/ # Data Access Layer │ │ ├── user_repository.go │ │ └── product_repository.go │ └── models/ # Domain Models │ ├── user.go │ └── product.go └── go.mod Layer 1: Domain Models package models import "time" // User represents a user in the system type User struct { ID int64 `json:"id"` Email string `json:"email"` Name string `json:"name"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // Product represents a product in the system type Product struct { ID int64 `json:"id"` Name string `json:"name"` Description string `json:"description"` Price float64 `json:"price"` Stock int `json:"stock"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // Order represents an order in the system type Order struct { ID int64 `json:"id"` UserID int64 `json:"user_id"` ProductID int64 `json:"product_id"` Quantity int `json:"quantity"` Total float64 `json:"total"` Status string `json:"status"` CreatedAt time.Time `json:"created_at"` } Layer 2: Data Access Layer (Repository) package repositories import ( "context" "database/sql" "fmt" "time" "myapp/internal/models" ) // UserRepository handles user data access type UserRepository struct { db *sql.DB } // NewUserRepository creates a new user repository func NewUserRepository(db *sql.DB) *UserRepository { return &UserRepository{db: db} } // Create creates a new user func (r *UserRepository) Create(ctx context.Context, user *models.User) error { query := ` INSERT INTO users (email, name, created_at, updated_at) VALUES ($1, $2, $3, $4) RETURNING id ` now := time.Now() user.CreatedAt = now user.UpdatedAt = now err := r.db.QueryRowContext( ctx, query, user.Email, user.Name, user.CreatedAt, user.UpdatedAt, ).Scan(&user.ID) if err != nil { return fmt.Errorf("failed to create user: %w", err) } return nil } // GetByID retrieves a user by ID func (r *UserRepository) GetByID(ctx context.Context, id int64) (*models.User, error) { query := ` SELECT id, email, name, created_at, updated_at FROM users WHERE id = $1 ` user := &models.User{} err := r.db.QueryRowContext(ctx, query, id).Scan( &user.ID, &user.Email, &user.Name, &user.CreatedAt, &user.UpdatedAt, ) if err == sql.ErrNoRows { return nil, fmt.Errorf("user not found") } if err != nil { return nil, fmt.Errorf("failed to get user: %w", err) } return user, nil } // GetByEmail retrieves a user by email func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*models.User, error) { query := ` SELECT id, email, name, created_at, updated_at FROM users WHERE email = $1 ` user := &models.User{} err := r.db.QueryRowContext(ctx, query, email).Scan( &user.ID, &user.Email, &user.Name, &user.CreatedAt, &user.UpdatedAt, ) if err == sql.ErrNoRows { return nil, fmt.Errorf("user not found") } if err != nil { return nil, fmt.Errorf("failed to get user: %w", err) } return user, nil } // Update updates a user func (r *UserRepository) Update(ctx context.Context, user *models.User) error { query := ` UPDATE users SET email = $1, name = $2, updated_at = $3 WHERE id = $4 ` user.UpdatedAt = time.Now() result, err := r.db.ExecContext( ctx, query, user.Email, user.Name, user.UpdatedAt, user.ID, ) if err != nil { return fmt.Errorf("failed to update user: %w", err) } rows, err := result.RowsAffected() if err != nil { return fmt.Errorf("failed to get affected rows: %w", err) } if rows == 0 { return fmt.Errorf("user not found") } return nil } // Delete deletes a user func (r *UserRepository) Delete(ctx context.Context, id int64) error { query := `DELETE FROM users WHERE id = $1` result, err := r.db.ExecContext(ctx, query, id) if err != nil { return fmt.Errorf("failed to delete user: %w", err) } rows, err := result.RowsAffected() if err != nil { return fmt.Errorf("failed to get affected rows: %w", err) } if rows == 0 { return fmt.Errorf("user not found") } return nil } // List retrieves all users func (r *UserRepository) List(ctx context.Context, limit, offset int) ([]*models.User, error) { query := ` SELECT id, email, name, created_at, updated_at FROM users ORDER BY id DESC LIMIT $1 OFFSET $2 ` rows, err := r.db.QueryContext(ctx, query, limit, offset) if err != nil { return nil, fmt.Errorf("failed to list users: %w", err) } defer rows.Close() var users []*models.User for rows.Next() { user := &models.User{} err := rows.Scan( &user.ID, &user.Email, &user.Name, &user.CreatedAt, &user.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("failed to scan user: %w", err) } users = append(users, user) } return users, nil } // ProductRepository handles product data access type ProductRepository struct { db *sql.DB } // NewProductRepository creates a new product repository func NewProductRepository(db *sql.DB) *ProductRepository { return &ProductRepository{db: db} } // UpdateStock updates product stock func (r *ProductRepository) UpdateStock(ctx context.Context, productID int64, quantity int) error { query := ` UPDATE products SET stock = stock + $1, updated_at = $2 WHERE id = $3 ` result, err := r.db.ExecContext(ctx, query, quantity, time.Now(), productID) if err != nil { return fmt.Errorf("failed to update stock: %w", err) } rows, err := result.RowsAffected() if err != nil { return fmt.Errorf("failed to get affected rows: %w", err) } if rows == 0 { return fmt.Errorf("product not found") } return nil } Layer 3: Business Logic Layer (Service) package services import ( "context" "fmt" "regexp" "myapp/internal/models" "myapp/internal/repositories" ) // UserService handles user business logic type UserService struct { userRepo *repositories.UserRepository } // NewUserService creates a new user service func NewUserService(userRepo *repositories.UserRepository) *UserService { return &UserService{ userRepo: userRepo, } } // CreateUser creates a new user with validation func (s *UserService) CreateUser(ctx context.Context, email, name string) (*models.User, error) { // Business rule: Validate email format if !isValidEmail(email) { return nil, fmt.Errorf("invalid email format") } // Business rule: Name must not be empty if name == "" { return nil, fmt.Errorf("name cannot be empty") } // Business rule: Email must be unique existingUser, err := s.userRepo.GetByEmail(ctx, email) if err == nil && existingUser != nil { return nil, fmt.Errorf("email already exists") } user := &models.User{ Email: email, Name: name, } if err := s.userRepo.Create(ctx, user); err != nil { return nil, fmt.Errorf("failed to create user: %w", err) } return user, nil } // GetUser retrieves a user by ID func (s *UserService) GetUser(ctx context.Context, id int64) (*models.User, error) { return s.userRepo.GetByID(ctx, id) } // UpdateUser updates a user with validation func (s *UserService) UpdateUser(ctx context.Context, id int64, email, name string) (*models.User, error) { // Business rule: Validate email format if !isValidEmail(email) { return nil, fmt.Errorf("invalid email format") } // Business rule: Name must not be empty if name == "" { return nil, fmt.Errorf("name cannot be empty") } user, err := s.userRepo.GetByID(ctx, id) if err != nil { return nil, fmt.Errorf("user not found: %w", err) } // Business rule: If email is changing, check uniqueness if user.Email != email { existingUser, err := s.userRepo.GetByEmail(ctx, email) if err == nil && existingUser != nil { return nil, fmt.Errorf("email already exists") } } user.Email = email user.Name = name if err := s.userRepo.Update(ctx, user); err != nil { return nil, fmt.Errorf("failed to update user: %w", err) } return user, nil } // DeleteUser deletes a user func (s *UserService) DeleteUser(ctx context.Context, id int64) error { return s.userRepo.Delete(ctx, id) } // ListUsers retrieves users with pagination func (s *UserService) ListUsers(ctx context.Context, page, pageSize int) ([]*models.User, error) { // Business rule: Validate pagination parameters if page < 1 { page = 1 } if pageSize < 1 || pageSize > 100 { pageSize = 20 } offset := (page - 1) * pageSize return s.userRepo.List(ctx, pageSize, offset) } // isValidEmail validates email format func isValidEmail(email string) bool { emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) return emailRegex.MatchString(email) } // OrderService handles order business logic type OrderService struct { userRepo *repositories.UserRepository productRepo *repositories.ProductRepository } // NewOrderService creates a new order service func NewOrderService( userRepo *repositories.UserRepository, productRepo *repositories.ProductRepository, ) *OrderService { return &OrderService{ userRepo: userRepo, productRepo: productRepo, } } // PlaceOrder places a new order with business validation func (s *OrderService) PlaceOrder(ctx context.Context, userID, productID int64, quantity int) (*models.Order, error) { // Business rule: Validate user exists user, err := s.userRepo.GetByID(ctx, userID) if err != nil { return nil, fmt.Errorf("user not found: %w", err) } // Business rule: Validate quantity if quantity <= 0 { return nil, fmt.Errorf("quantity must be positive") } // Business rule: Check stock availability // (In a real implementation, this would be in ProductRepository) // Business rule: Calculate total // (In a real implementation, this would fetch product price) order := &models.Order{ UserID: user.ID, ProductID: productID, Quantity: quantity, Status: "pending", } return order, nil } Layer 4: Presentation Layer (HTTP Handlers) package handlers import ( "encoding/json" "net/http" "strconv" "myapp/internal/services" ) // UserHandler handles HTTP requests for users type UserHandler struct { userService *services.UserService } // NewUserHandler creates a new user handler func NewUserHandler(userService *services.UserService) *UserHandler { return &UserHandler{ userService: userService, } } // CreateUserRequest represents the request to create a user type CreateUserRequest struct { Email string `json:"email"` Name string `json:"name"` } // CreateUser handles POST /users func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) { var req CreateUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respondError(w, http.StatusBadRequest, "invalid request body") return } user, err := h.userService.CreateUser(r.Context(), req.Email, req.Name) if err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } respondJSON(w, http.StatusCreated, user) } // GetUser handles GET /users/:id func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) { // Extract ID from URL (implementation depends on router) id, err := extractIDFromPath(r) if err != nil { respondError(w, http.StatusBadRequest, "invalid user ID") return } user, err := h.userService.GetUser(r.Context(), id) if err != nil { respondError(w, http.StatusNotFound, "user not found") return } respondJSON(w, http.StatusOK, user) } // UpdateUser handles PUT /users/:id func (h *UserHandler) UpdateUser(w http.ResponseWriter, r *http.Request) { id, err := extractIDFromPath(r) if err != nil { respondError(w, http.StatusBadRequest, "invalid user ID") return } var req CreateUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respondError(w, http.StatusBadRequest, "invalid request body") return } user, err := h.userService.UpdateUser(r.Context(), id, req.Email, req.Name) if err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } respondJSON(w, http.StatusOK, user) } // DeleteUser handles DELETE /users/:id func (h *UserHandler) DeleteUser(w http.ResponseWriter, r *http.Request) { id, err := extractIDFromPath(r) if err != nil { respondError(w, http.StatusBadRequest, "invalid user ID") return } if err := h.userService.DeleteUser(r.Context(), id); err != nil { respondError(w, http.StatusNotFound, err.Error()) return } w.WriteHeader(http.StatusNoContent) } // ListUsers handles GET /users func (h *UserHandler) ListUsers(w http.ResponseWriter, r *http.Request) { page, _ := strconv.Atoi(r.URL.Query().Get("page")) pageSize, _ := strconv.Atoi(r.URL.Query().Get("page_size")) users, err := h.userService.ListUsers(r.Context(), page, pageSize) if err != nil { respondError(w, http.StatusInternalServerError, "failed to list users") return } respondJSON(w, http.StatusOK, users) } // Helper functions func respondJSON(w http.ResponseWriter, status int, data interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(data) } func respondError(w http.ResponseWriter, status int, message string) { respondJSON(w, status, map[string]string{"error": message}) } func extractIDFromPath(r *http.Request) (int64, error) { // This is a simplified version - use your router's method // For example with chi: chi.URLParam(r, "id") return 1, nil } Main Application package main import ( "database/sql" "log" "net/http" _ "github.com/lib/pq" "myapp/internal/handlers" "myapp/internal/repositories" "myapp/internal/services" ) func main() { // Initialize database connection db, err := sql.Open("postgres", "postgres://user:pass@localhost/dbname?sslmode=disable") if err != nil { log.Fatal(err) } defer db.Close() // Initialize layers from bottom to top // Data Access Layer userRepo := repositories.NewUserRepository(db) productRepo := repositories.NewProductRepository(db) // Business Logic Layer userService := services.NewUserService(userRepo) orderService := services.NewOrderService(userRepo, productRepo) // Presentation Layer userHandler := handlers.NewUserHandler(userService) // Setup routes mux := http.NewServeMux() mux.HandleFunc("POST /users", userHandler.CreateUser) mux.HandleFunc("GET /users/{id}", userHandler.GetUser) mux.HandleFunc("PUT /users/{id}", userHandler.UpdateUser) mux.HandleFunc("DELETE /users/{id}", userHandler.DeleteUser) mux.HandleFunc("GET /users", userHandler.ListUsers) // Start server log.Println("Server starting on :8080") if err := http.ListenAndServe(":8080", mux); err != nil { log.Fatal(err) } } Testing Layered Architecture package services_test import ( "context" "testing" "myapp/internal/models" "myapp/internal/services" ) // MockUserRepository for testing type MockUserRepository struct { users map[int64]*models.User nextID int64 } func NewMockUserRepository() *MockUserRepository { return &MockUserRepository{ users: make(map[int64]*models.User), nextID: 1, } } func (m *MockUserRepository) Create(ctx context.Context, user *models.User) error { user.ID = m.nextID m.nextID++ m.users[user.ID] = user return nil } func (m *MockUserRepository) GetByID(ctx context.Context, id int64) (*models.User, error) { user, exists := m.users[id] if !exists { return nil, fmt.Errorf("user not found") } return user, nil } func (m *MockUserRepository) GetByEmail(ctx context.Context, email string) (*models.User, error) { for _, user := range m.users { if user.Email == email { return user, nil } } return nil, fmt.Errorf("user not found") } func TestUserService_CreateUser(t *testing.T) { mockRepo := NewMockUserRepository() service := services.NewUserService(mockRepo) tests := []struct { name string email string userName string wantErr bool }{ { name: "valid user", email: "[email protected]", userName: "Test User", wantErr: false, }, { name: "invalid email", email: "invalid-email", userName: "Test User", wantErr: true, }, { name: "empty name", email: "[email protected]", userName: "", wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { user, err := service.CreateUser(context.Background(), tt.email, tt.userName) if tt.wantErr { if err == nil { t.Errorf("expected error but got none") } return } if err != nil { t.Errorf("unexpected error: %v", err) return } if user.Email != tt.email { t.Errorf("expected email %s, got %s", tt.email, user.Email) } }) } } Best Practices Clear Layer Boundaries: Define clear interfaces between layers Dependency Direction: Always depend downward, never upward Keep Layers Thin: Avoid bloated service layers with too much logic Use Dependency Injection: Inject dependencies rather than creating them Interface Segregation: Define minimal interfaces for layer communication Error Handling: Handle errors appropriately at each layer Testing: Test each layer independently with mocks Common Pitfalls Layer Violation: Skipping layers (e.g., handler directly accessing repository) Anemic Domain Models: Models with no behavior, only data Fat Service Layer: Putting all logic in the service layer Tight Coupling: Layers knowing too much about each other God Objects: Services or repositories handling too many responsibilities Inconsistent Abstractions: Different patterns in different layers When to Use Layered Architecture Use When: ...

    January 14, 2025 · 13 min · Rafiul Alam

    Clean Architecture in Go: Building Independent and Testable Systems

    Go Architecture Patterns Series: ← Layered Architecture | Series Overview | Next: Hexagonal Architecture → What is Clean Architecture? Clean Architecture, popularized by Robert C. Martin (Uncle Bob), is an architectural pattern that emphasizes separation of concerns and independence from frameworks, databases, and external agencies. It organizes code in concentric circles with dependencies pointing inward. Key Principles: Independence of Frameworks: Architecture doesn’t depend on frameworks Testability: Business rules can be tested without UI, database, or external elements Independence of UI: UI can change without changing business rules Independence of Database: Business rules don’t know about the database Independence of External Agency: Business rules don’t know about the outside world Dependency Rule: Dependencies only point inward toward higher-level policies Architecture Overview %%{init: {'theme':'dark', 'themeVariables': {'primaryTextColor':'#e5e7eb','secondaryTextColor':'#e5e7eb','tertiaryTextColor':'#e5e7eb','textColor':'#e5e7eb','nodeTextColor':'#e5e7eb','edgeLabelText':'#e5e7eb','clusterTextColor':'#e5e7eb','actorTextColor':'#e5e7eb'}}}%% graph TD subgraph "External Layer (Frameworks & Drivers)" A1[Web Framework] A2[Database] A3[External APIs] end subgraph "Interface Adapters" B1[Controllers] B2[Presenters] B3[Gateways] end subgraph "Use Cases (Application Business Rules)" C1[Interactors] C2[Use Case Logic] end subgraph "Entities (Enterprise Business Rules)" D1[Domain Models] D2[Business Logic] end A1 --> B1 A2 --> B3 B1 --> C1 B3 --> C1 C1 --> D1 style D1 fill:#78350f,color:#fff style C1 fill:#1e3a5f,color:#fff style B1 fill:#134e4a,color:#fff style A1 fill:#4c1d95,color:#fff Clean Architecture Circles %%{init: {'theme':'dark', 'themeVariables': {'primaryTextColor':'#e5e7eb','secondaryTextColor':'#e5e7eb','tertiaryTextColor':'#e5e7eb','textColor':'#e5e7eb','nodeTextColor':'#e5e7eb','edgeLabelText':'#e5e7eb','clusterTextColor':'#e5e7eb','actorTextColor':'#e5e7eb'}}}%% graph LR subgraph "Layer 1: Entities" E[Core BusinessRules & Models] end subgraph "Layer 2: Use Cases" U[ApplicationBusiness Rules] end subgraph "Layer 3: Interface Adapters" I[ControllersPresentersGateways] end subgraph "Layer 4: Frameworks & Drivers" F[WebDBUI] end F -.->|depends on| I I -.->|depends on| U U -.->|depends on| E style E fill:#78350f,color:#fff style U fill:#1e3a5f,color:#fff style I fill:#134e4a,color:#fff style F fill:#4c1d95,color:#fff Dependency Flow %%{init: {'theme':'dark', 'themeVariables': {'primaryTextColor':'#e5e7eb','secondaryTextColor':'#e5e7eb','tertiaryTextColor':'#e5e7eb','textColor':'#e5e7eb','nodeTextColor':'#e5e7eb','edgeLabelText':'#e5e7eb','clusterTextColor':'#e5e7eb','actorTextColor':'#e5e7eb'}}}%% graph TB subgraph "Outer Layers (Low-level Details)" direction LR DB[Database] Web[Web Server] API[External APIs] end subgraph "Interface Adapters" direction LR Repo[RepositoryImplementation] Controller[HTTP Controller] Gateway[API Gateway] end subgraph "Use Cases" direction LR UC1[Create UserUse Case] UC2[Get UserUse Case] end subgraph "Entities (Core)" direction LR Entity[User Entity] Rules[Business Rules] end DB --> Repo Web --> Controller API --> Gateway Repo -.->|implements| UC1 Controller -.->|calls| UC1 Gateway -.->|implements| UC2 UC1 -.->|uses| Entity UC2 -.->|uses| Entity style Entity fill:#78350f,stroke:#fb923c,stroke-width:3px,color:#fff style UC1 fill:#1e3a5f,color:#fff style Repo fill:#134e4a,color:#fff style DB fill:#4c1d95,color:#fff Real-World Use Cases Enterprise Applications: Complex business logic that needs isolation Long-lived Systems: Applications that need to evolve over time Multi-platform Applications: Same core logic, different interfaces Testing-Critical Systems: Financial, healthcare, or mission-critical apps API-first Applications: Where business logic is reused across interfaces Microservices: Each service following clean architecture principles Project Structure ├── cmd/ │ └── api/ │ └── main.go ├── internal/ │ ├── entity/ # Layer 1: Entities │ │ ├── user.go │ │ └── errors.go │ ├── usecase/ # Layer 2: Use Cases │ │ ├── user_usecase.go │ │ ├── interfaces.go # Repository & Presenter interfaces │ │ └── user_interactor.go │ ├── adapter/ # Layer 3: Interface Adapters │ │ ├── repository/ │ │ │ └── user_repository.go │ │ ├── presenter/ │ │ │ └── user_presenter.go │ │ └── controller/ │ │ └── user_controller.go │ └── infrastructure/ # Layer 4: Frameworks & Drivers │ ├── database/ │ │ └── postgres.go │ ├── router/ │ │ └── router.go │ └── config/ │ └── config.go └── go.mod Layer 1: Entities (Core Business Rules) package entity import ( "errors" "regexp" "time" ) // User represents the core user entity with business rules type User struct { ID string Email string Name string Age int Status UserStatus CreatedAt time.Time UpdatedAt time.Time } // UserStatus represents user account status type UserStatus string const ( UserStatusActive UserStatus = "active" UserStatusInactive UserStatus = "inactive" UserStatusSuspended UserStatus = "suspended" ) // Business rule validation errors var ( ErrInvalidEmail = errors.New("invalid email format") ErrInvalidName = errors.New("name must not be empty") ErrInvalidAge = errors.New("age must be between 0 and 150") ErrUserNotFound = errors.New("user not found") ErrUserAlreadyExists = errors.New("user already exists") ErrUnauthorized = errors.New("unauthorized action") ) // NewUser creates a new user with business rule validation func NewUser(email, name string, age int) (*User, error) { user := &User{ Email: email, Name: name, Age: age, Status: UserStatusActive, CreatedAt: time.Now(), UpdatedAt: time.Now(), } if err := user.Validate(); err != nil { return nil, err } return user, nil } // Validate validates the user entity according to business rules func (u *User) Validate() error { // Business Rule: Email must be valid format if !isValidEmail(u.Email) { return ErrInvalidEmail } // Business Rule: Name must not be empty if u.Name == "" { return ErrInvalidName } // Business Rule: Age must be realistic if u.Age < 0 || u.Age > 150 { return ErrInvalidAge } return nil } // UpdateEmail updates user email with validation func (u *User) UpdateEmail(email string) error { if !isValidEmail(email) { return ErrInvalidEmail } u.Email = email u.UpdatedAt = time.Now() return nil } // UpdateName updates user name with validation func (u *User) UpdateName(name string) error { if name == "" { return ErrInvalidName } u.Name = name u.UpdatedAt = time.Now() return nil } // Activate activates the user account func (u *User) Activate() { u.Status = UserStatusActive u.UpdatedAt = time.Now() } // Deactivate deactivates the user account func (u *User) Deactivate() { u.Status = UserStatusInactive u.UpdatedAt = time.Now() } // Suspend suspends the user account func (u *User) Suspend() { u.Status = UserStatusSuspended u.UpdatedAt = time.Now() } // IsActive checks if user is active func (u *User) IsActive() bool { return u.Status == UserStatusActive } // CanPerformAction checks if user can perform actions (business rule) func (u *User) CanPerformAction() bool { return u.Status == UserStatusActive } // isValidEmail validates email format func isValidEmail(email string) bool { emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) return emailRegex.MatchString(email) } // Product entity with business rules type Product struct { ID string Name string Description string Price Money Stock int CreatedAt time.Time UpdatedAt time.Time } // Money represents monetary value (value object) type Money struct { Amount int64 // in cents Currency string } // NewMoney creates a new money value object func NewMoney(amount int64, currency string) Money { return Money{ Amount: amount, Currency: currency, } } // Add adds two money values func (m Money) Add(other Money) (Money, error) { if m.Currency != other.Currency { return Money{}, errors.New("currency mismatch") } return Money{ Amount: m.Amount + other.Amount, Currency: m.Currency, }, nil } // Multiply multiplies money by a factor func (m Money) Multiply(factor int) Money { return Money{ Amount: m.Amount * int64(factor), Currency: m.Currency, } } Layer 2: Use Cases (Application Business Rules) Use Case Interfaces package usecase import ( "context" "myapp/internal/entity" ) // UserRepository defines the interface for user data access // This interface is defined in the use case layer but implemented in outer layers type UserRepository interface { Create(ctx context.Context, user *entity.User) error GetByID(ctx context.Context, id string) (*entity.User, error) GetByEmail(ctx context.Context, email string) (*entity.User, error) Update(ctx context.Context, user *entity.User) error Delete(ctx context.Context, id string) error List(ctx context.Context, offset, limit int) ([]*entity.User, error) } // UserPresenter defines the interface for presenting user data type UserPresenter interface { PresentUser(user *entity.User) interface{} PresentUsers(users []*entity.User) interface{} PresentError(err error) interface{} } // EmailService defines the interface for email operations type EmailService interface { SendWelcomeEmail(ctx context.Context, user *entity.User) error SendPasswordResetEmail(ctx context.Context, user *entity.User, token string) error } // IDGenerator defines the interface for generating IDs type IDGenerator interface { Generate() string } Use Case Implementation package usecase import ( "context" "fmt" "myapp/internal/entity" ) // CreateUserInput represents input for creating a user type CreateUserInput struct { Email string Name string Age int } // UpdateUserInput represents input for updating a user type UpdateUserInput struct { ID string Email string Name string Age int } // UserInteractor implements user use cases type UserInteractor struct { repo UserRepository emailService EmailService idGen IDGenerator } // NewUserInteractor creates a new user interactor func NewUserInteractor( repo UserRepository, emailService EmailService, idGen IDGenerator, ) *UserInteractor { return &UserInteractor{ repo: repo, emailService: emailService, idGen: idGen, } } // CreateUser creates a new user (use case) func (i *UserInteractor) CreateUser(ctx context.Context, input CreateUserInput) (*entity.User, error) { // Use case logic: Check if user already exists existingUser, err := i.repo.GetByEmail(ctx, input.Email) if err == nil && existingUser != nil { return nil, entity.ErrUserAlreadyExists } // Use case logic: Create new user entity user, err := entity.NewUser(input.Email, input.Name, input.Age) if err != nil { return nil, fmt.Errorf("failed to create user entity: %w", err) } // Use case logic: Generate ID user.ID = i.idGen.Generate() // Use case logic: Save user if err := i.repo.Create(ctx, user); err != nil { return nil, fmt.Errorf("failed to save user: %w", err) } // Use case logic: Send welcome email (async in real system) if err := i.emailService.SendWelcomeEmail(ctx, user); err != nil { // Log error but don't fail the use case fmt.Printf("failed to send welcome email: %v\n", err) } return user, nil } // GetUser retrieves a user by ID (use case) func (i *UserInteractor) GetUser(ctx context.Context, id string) (*entity.User, error) { user, err := i.repo.GetByID(ctx, id) if err != nil { return nil, fmt.Errorf("failed to get user: %w", err) } if user == nil { return nil, entity.ErrUserNotFound } return user, nil } // UpdateUser updates a user (use case) func (i *UserInteractor) UpdateUser(ctx context.Context, input UpdateUserInput) (*entity.User, error) { // Use case logic: Get existing user user, err := i.repo.GetByID(ctx, input.ID) if err != nil { return nil, entity.ErrUserNotFound } // Use case logic: Check if email is changing and if it already exists if user.Email != input.Email { existingUser, err := i.repo.GetByEmail(ctx, input.Email) if err == nil && existingUser != nil { return nil, entity.ErrUserAlreadyExists } } // Use case logic: Update user fields if err := user.UpdateEmail(input.Email); err != nil { return nil, err } if err := user.UpdateName(input.Name); err != nil { return nil, err } user.Age = input.Age if err := user.Validate(); err != nil { return nil, err } // Use case logic: Save updated user if err := i.repo.Update(ctx, user); err != nil { return nil, fmt.Errorf("failed to update user: %w", err) } return user, nil } // DeleteUser deletes a user (use case) func (i *UserInteractor) DeleteUser(ctx context.Context, id string) error { // Use case logic: Verify user exists user, err := i.repo.GetByID(ctx, id) if err != nil { return entity.ErrUserNotFound } // Use case logic: Check if user can be deleted (business rule) if !user.CanPerformAction() { return entity.ErrUnauthorized } // Use case logic: Delete user if err := i.repo.Delete(ctx, id); err != nil { return fmt.Errorf("failed to delete user: %w", err) } return nil } // ListUsers lists users with pagination (use case) func (i *UserInteractor) ListUsers(ctx context.Context, page, pageSize int) ([]*entity.User, error) { // Use case logic: Validate pagination if page < 1 { page = 1 } if pageSize < 1 || pageSize > 100 { pageSize = 20 } offset := (page - 1) * pageSize // Use case logic: Get users users, err := i.repo.List(ctx, offset, pageSize) if err != nil { return nil, fmt.Errorf("failed to list users: %w", err) } return users, nil } // ActivateUser activates a user account (use case) func (i *UserInteractor) ActivateUser(ctx context.Context, id string) error { user, err := i.repo.GetByID(ctx, id) if err != nil { return entity.ErrUserNotFound } user.Activate() if err := i.repo.Update(ctx, user); err != nil { return fmt.Errorf("failed to activate user: %w", err) } return nil } Layer 3: Interface Adapters Repository Implementation package repository import ( "context" "database/sql" "fmt" "time" "myapp/internal/entity" ) // PostgresUserRepository implements UserRepository interface type PostgresUserRepository struct { db *sql.DB } // NewPostgresUserRepository creates a new Postgres user repository func NewPostgresUserRepository(db *sql.DB) *PostgresUserRepository { return &PostgresUserRepository{db: db} } // Create creates a new user in the database func (r *PostgresUserRepository) Create(ctx context.Context, user *entity.User) error { query := ` INSERT INTO users (id, email, name, age, status, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7) ` _, err := r.db.ExecContext( ctx, query, user.ID, user.Email, user.Name, user.Age, user.Status, user.CreatedAt, user.UpdatedAt, ) if err != nil { return fmt.Errorf("failed to create user: %w", err) } return nil } // GetByID retrieves a user by ID func (r *PostgresUserRepository) GetByID(ctx context.Context, id string) (*entity.User, error) { query := ` SELECT id, email, name, age, status, created_at, updated_at FROM users WHERE id = $1 ` var user entity.User var status string err := r.db.QueryRowContext(ctx, query, id).Scan( &user.ID, &user.Email, &user.Name, &user.Age, &status, &user.CreatedAt, &user.UpdatedAt, ) if err == sql.ErrNoRows { return nil, entity.ErrUserNotFound } if err != nil { return nil, fmt.Errorf("failed to get user: %w", err) } user.Status = entity.UserStatus(status) return &user, nil } // GetByEmail retrieves a user by email func (r *PostgresUserRepository) GetByEmail(ctx context.Context, email string) (*entity.User, error) { query := ` SELECT id, email, name, age, status, created_at, updated_at FROM users WHERE email = $1 ` var user entity.User var status string err := r.db.QueryRowContext(ctx, query, email).Scan( &user.ID, &user.Email, &user.Name, &user.Age, &status, &user.CreatedAt, &user.UpdatedAt, ) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("failed to get user: %w", err) } user.Status = entity.UserStatus(status) return &user, nil } // Update updates a user func (r *PostgresUserRepository) Update(ctx context.Context, user *entity.User) error { query := ` UPDATE users SET email = $1, name = $2, age = $3, status = $4, updated_at = $5 WHERE id = $6 ` result, err := r.db.ExecContext( ctx, query, user.Email, user.Name, user.Age, user.Status, time.Now(), user.ID, ) if err != nil { return fmt.Errorf("failed to update user: %w", err) } rows, err := result.RowsAffected() if err != nil { return fmt.Errorf("failed to get affected rows: %w", err) } if rows == 0 { return entity.ErrUserNotFound } return nil } // Delete deletes a user func (r *PostgresUserRepository) Delete(ctx context.Context, id string) error { query := `DELETE FROM users WHERE id = $1` result, err := r.db.ExecContext(ctx, query, id) if err != nil { return fmt.Errorf("failed to delete user: %w", err) } rows, err := result.RowsAffected() if err != nil { return fmt.Errorf("failed to get affected rows: %w", err) } if rows == 0 { return entity.ErrUserNotFound } return nil } // List retrieves users with pagination func (r *PostgresUserRepository) List(ctx context.Context, offset, limit int) ([]*entity.User, error) { query := ` SELECT id, email, name, age, status, created_at, updated_at FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2 ` rows, err := r.db.QueryContext(ctx, query, limit, offset) if err != nil { return nil, fmt.Errorf("failed to list users: %w", err) } defer rows.Close() var users []*entity.User for rows.Next() { var user entity.User var status string err := rows.Scan( &user.ID, &user.Email, &user.Name, &user.Age, &status, &user.CreatedAt, &user.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("failed to scan user: %w", err) } user.Status = entity.UserStatus(status) users = append(users, &user) } return users, nil } Controller Implementation package controller import ( "encoding/json" "net/http" "strconv" "myapp/internal/entity" "myapp/internal/usecase" ) // UserController handles HTTP requests for users type UserController struct { interactor *usecase.UserInteractor presenter usecase.UserPresenter } // NewUserController creates a new user controller func NewUserController( interactor *usecase.UserInteractor, presenter usecase.UserPresenter, ) *UserController { return &UserController{ interactor: interactor, presenter: presenter, } } // CreateUserRequest represents the HTTP request for creating a user type CreateUserRequest struct { Email string `json:"email"` Name string `json:"name"` Age int `json:"age"` } // CreateUser handles POST /users func (c *UserController) CreateUser(w http.ResponseWriter, r *http.Request) { var req CreateUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { c.respondError(w, http.StatusBadRequest, err) return } input := usecase.CreateUserInput{ Email: req.Email, Name: req.Name, Age: req.Age, } user, err := c.interactor.CreateUser(r.Context(), input) if err != nil { switch err { case entity.ErrInvalidEmail, entity.ErrInvalidName, entity.ErrInvalidAge: c.respondError(w, http.StatusBadRequest, err) case entity.ErrUserAlreadyExists: c.respondError(w, http.StatusConflict, err) default: c.respondError(w, http.StatusInternalServerError, err) } return } c.respond(w, http.StatusCreated, c.presenter.PresentUser(user)) } // GetUser handles GET /users/:id func (c *UserController) GetUser(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") user, err := c.interactor.GetUser(r.Context(), id) if err != nil { if err == entity.ErrUserNotFound { c.respondError(w, http.StatusNotFound, err) } else { c.respondError(w, http.StatusInternalServerError, err) } return } c.respond(w, http.StatusOK, c.presenter.PresentUser(user)) } // UpdateUser handles PUT /users/:id func (c *UserController) UpdateUser(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") var req CreateUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { c.respondError(w, http.StatusBadRequest, err) return } input := usecase.UpdateUserInput{ ID: id, Email: req.Email, Name: req.Name, Age: req.Age, } user, err := c.interactor.UpdateUser(r.Context(), input) if err != nil { switch err { case entity.ErrUserNotFound: c.respondError(w, http.StatusNotFound, err) case entity.ErrInvalidEmail, entity.ErrInvalidName, entity.ErrInvalidAge: c.respondError(w, http.StatusBadRequest, err) case entity.ErrUserAlreadyExists: c.respondError(w, http.StatusConflict, err) default: c.respondError(w, http.StatusInternalServerError, err) } return } c.respond(w, http.StatusOK, c.presenter.PresentUser(user)) } // DeleteUser handles DELETE /users/:id func (c *UserController) DeleteUser(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if err := c.interactor.DeleteUser(r.Context(), id); err != nil { if err == entity.ErrUserNotFound { c.respondError(w, http.StatusNotFound, err) } else { c.respondError(w, http.StatusInternalServerError, err) } return } w.WriteHeader(http.StatusNoContent) } // ListUsers handles GET /users func (c *UserController) ListUsers(w http.ResponseWriter, r *http.Request) { page, _ := strconv.Atoi(r.URL.Query().Get("page")) pageSize, _ := strconv.Atoi(r.URL.Query().Get("page_size")) users, err := c.interactor.ListUsers(r.Context(), page, pageSize) if err != nil { c.respondError(w, http.StatusInternalServerError, err) return } c.respond(w, http.StatusOK, c.presenter.PresentUsers(users)) } // Helper methods func (c *UserController) respond(w http.ResponseWriter, status int, data interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(data) } func (c *UserController) respondError(w http.ResponseWriter, status int, err error) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(c.presenter.PresentError(err)) } Presenter Implementation package presenter import ( "myapp/internal/entity" ) // UserJSONPresenter presents users as JSON type UserJSONPresenter struct{} // NewUserJSONPresenter creates a new user JSON presenter func NewUserJSONPresenter() *UserJSONPresenter { return &UserJSONPresenter{} } // UserResponse represents the JSON response for a user type UserResponse struct { ID string `json:"id"` Email string `json:"email"` Name string `json:"name"` Age int `json:"age"` Status string `json:"status"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } // PresentUser presents a single user func (p *UserJSONPresenter) PresentUser(user *entity.User) interface{} { return UserResponse{ ID: user.ID, Email: user.Email, Name: user.Name, Age: user.Age, Status: string(user.Status), CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z"), UpdatedAt: user.UpdatedAt.Format("2006-01-02T15:04:05Z"), } } // PresentUsers presents multiple users func (p *UserJSONPresenter) PresentUsers(users []*entity.User) interface{} { responses := make([]UserResponse, len(users)) for i, user := range users { responses[i] = UserResponse{ ID: user.ID, Email: user.Email, Name: user.Name, Age: user.Age, Status: string(user.Status), CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z"), UpdatedAt: user.UpdatedAt.Format("2006-01-02T15:04:05Z"), } } return map[string]interface{}{ "users": responses, "count": len(responses), } } // ErrorResponse represents an error response type ErrorResponse struct { Error string `json:"error"` Code string `json:"code,omitempty"` } // PresentError presents an error func (p *UserJSONPresenter) PresentError(err error) interface{} { return ErrorResponse{ Error: err.Error(), } } Layer 4: Main Application (Dependency Injection) package main import ( "database/sql" "log" "net/http" _ "github.com/lib/pq" "github.com/google/uuid" "myapp/internal/adapter/controller" "myapp/internal/adapter/presenter" "myapp/internal/adapter/repository" "myapp/internal/usecase" ) // UUIDGenerator implements IDGenerator type UUIDGenerator struct{} func (g *UUIDGenerator) Generate() string { return uuid.New().String() } // MockEmailService implements EmailService type MockEmailService struct{} func (s *MockEmailService) SendWelcomeEmail(ctx context.Context, user *entity.User) error { log.Printf("Sending welcome email to %s", user.Email) return nil } func (s *MockEmailService) SendPasswordResetEmail(ctx context.Context, user *entity.User, token string) error { log.Printf("Sending password reset email to %s with token %s", user.Email, token) return nil } func main() { // Initialize database db, err := sql.Open("postgres", "postgres://user:pass@localhost/cleanarch?sslmode=disable") if err != nil { log.Fatal(err) } defer db.Close() // Layer 4: Initialize adapters (implementations) userRepo := repository.NewPostgresUserRepository(db) emailService := &MockEmailService{} idGen := &UUIDGenerator{} userPresenter := presenter.NewUserJSONPresenter() // Layer 2: Initialize use cases (with dependency injection) userInteractor := usecase.NewUserInteractor(userRepo, emailService, idGen) // Layer 3: Initialize controllers userController := controller.NewUserController(userInteractor, userPresenter) // Setup routes mux := http.NewServeMux() mux.HandleFunc("POST /users", userController.CreateUser) mux.HandleFunc("GET /users/{id}", userController.GetUser) mux.HandleFunc("PUT /users/{id}", userController.UpdateUser) mux.HandleFunc("DELETE /users/{id}", userController.DeleteUser) mux.HandleFunc("GET /users", userController.ListUsers) // Start server log.Println("Server starting on :8080") if err := http.ListenAndServe(":8080", mux); err != nil { log.Fatal(err) } } Testing in Clean Architecture package usecase_test import ( "context" "testing" "myapp/internal/entity" "myapp/internal/usecase" ) // Mock implementations type MockUserRepository struct { users map[string]*entity.User } func NewMockUserRepository() *MockUserRepository { return &MockUserRepository{ users: make(map[string]*entity.User), } } func (m *MockUserRepository) Create(ctx context.Context, user *entity.User) error { m.users[user.ID] = user return nil } func (m *MockUserRepository) GetByID(ctx context.Context, id string) (*entity.User, error) { user, exists := m.users[id] if !exists { return nil, entity.ErrUserNotFound } return user, nil } func (m *MockUserRepository) GetByEmail(ctx context.Context, email string) (*entity.User, error) { for _, user := range m.users { if user.Email == email { return user, nil } } return nil, nil } type MockEmailService struct { sentEmails []string } func (m *MockEmailService) SendWelcomeEmail(ctx context.Context, user *entity.User) error { m.sentEmails = append(m.sentEmails, user.Email) return nil } type MockIDGenerator struct { nextID int } func (m *MockIDGenerator) Generate() string { m.nextID++ return fmt.Sprintf("user-%d", m.nextID) } func TestCreateUser(t *testing.T) { // Arrange repo := NewMockUserRepository() emailService := &MockEmailService{} idGen := &MockIDGenerator{} interactor := usecase.NewUserInteractor(repo, emailService, idGen) input := usecase.CreateUserInput{ Email: "[email protected]", Name: "Test User", Age: 25, } // Act user, err := interactor.CreateUser(context.Background(), input) // Assert if err != nil { t.Fatalf("expected no error, got %v", err) } if user.Email != input.Email { t.Errorf("expected email %s, got %s", input.Email, user.Email) } if len(emailService.sentEmails) != 1 { t.Errorf("expected 1 welcome email, got %d", len(emailService.sentEmails)) } } Best Practices Dependency Rule: Always point dependencies inward Interface Segregation: Define minimal interfaces in use case layer Dependency Injection: Inject all dependencies explicitly Entity Purity: Keep entities free from framework dependencies Use Case Focus: Each use case should have a single responsibility Test Independence: Test each layer independently Avoid Anemic Models: Put business logic in entities Common Pitfalls Breaking Dependency Rule: Outer layers should not be imported by inner layers Leaking Infrastructure: Database or framework details leaking into entities Fat Use Cases: Use cases doing too much or too little Ignoring Presenters: Directly returning entities from controllers Over-engineering: Applying Clean Architecture to simple CRUD apps Missing Boundaries: Not clearly defining layer boundaries When to Use Clean Architecture Use When: ...

    January 13, 2025 · 18 min · Rafiul Alam

    Hexagonal Architecture in Go: Ports and Adapters Pattern

    Go Architecture Patterns Series: ← Clean Architecture | Series Overview | Next: Domain-Driven Design → What is Hexagonal Architecture? Hexagonal Architecture, also known as Ports and Adapters pattern, was introduced by Alistair Cockburn. It emphasizes separating the core business logic from external concerns by defining clear boundaries through ports (interfaces) and adapters (implementations). Key Principles: Core Domain Isolation: Business logic is completely isolated from external dependencies Ports: Interfaces that define how the application communicates with the outside world Adapters: Concrete implementations of ports for specific technologies Symmetry: No distinction between “front-end” and “back-end” - all external systems are treated equally Testability: Core can be tested in isolation without any external dependencies Pluggability: Adapters can be swapped without changing the core Architecture Overview %%{init: {'theme':'dark', 'themeVariables': {'primaryTextColor':'#e5e7eb','secondaryTextColor':'#e5e7eb','tertiaryTextColor':'#e5e7eb','textColor':'#e5e7eb','nodeTextColor':'#e5e7eb','edgeLabelText':'#e5e7eb','clusterTextColor':'#e5e7eb','actorTextColor':'#e5e7eb'}}}%% graph TB subgraph "Primary Adapters (Driving)" HTTP[HTTP REST API] CLI[CLI Interface] GRPC[gRPC Server] end subgraph "Hexagon (Core Domain)" PORT1[Primary PortsDriving Interfaces] CORE[Business LogicDomain ModelsUse Cases] PORT2[Secondary PortsDriven Interfaces] end subgraph "Secondary Adapters (Driven)" DB[PostgreSQL Adapter] CACHE[Redis Adapter] MSG[Message Queue Adapter] EXT[External API Adapter] end HTTP --> PORT1 CLI --> PORT1 GRPC --> PORT1 PORT1 --> CORE CORE --> PORT2 PORT2 --> DB PORT2 --> CACHE PORT2 --> MSG PORT2 --> EXT style CORE fill:#78350f,color:#fff style PORT1 fill:#1e3a5f,color:#fff style PORT2 fill:#1e3a5f,color:#fff style HTTP fill:#134e4a,color:#fff style DB fill:#4c1d95,color:#fff Ports and Adapters Visualization %%{init: {'theme':'dark', 'themeVariables': {'primaryTextColor':'#e5e7eb','secondaryTextColor':'#e5e7eb','tertiaryTextColor':'#e5e7eb','textColor':'#e5e7eb','nodeTextColor':'#e5e7eb','edgeLabelText':'#e5e7eb','clusterTextColor':'#e5e7eb','actorTextColor':'#e5e7eb'}}}%% graph LR subgraph "Primary Side (Driving)" REST[REST Adapter] GraphQL[GraphQL Adapter] end subgraph "Core Application" IP[Input PortInterface] BL[Business Logic] OP[Output PortInterface] end subgraph "Secondary Side (Driven)" DBAdapter[Database Adapter] EmailAdapter[Email Adapter] end REST -.->|implements| IP GraphQL -.->|implements| IP IP --> BL BL --> OP OP -.->|implemented by| DBAdapter OP -.->|implemented by| EmailAdapter style BL fill:#78350f,color:#fff style IP fill:#1e3a5f,color:#fff style OP fill:#1e3a5f,color:#fff style REST fill:#134e4a,color:#fff style DBAdapter fill:#4c1d95,color:#fff Complete Hexagonal Flow %%{init: {'theme':'dark', 'themeVariables': {'primaryTextColor':'#e5e7eb','secondaryTextColor':'#e5e7eb','tertiaryTextColor':'#e5e7eb','textColor':'#e5e7eb','nodeTextColor':'#e5e7eb','edgeLabelText':'#e5e7eb','clusterTextColor':'#e5e7eb','actorTextColor':'#e5e7eb'}}}%% sequenceDiagram participant Client participant HTTPAdapter participant InputPort participant Core participant OutputPort participant DBAdapter participant Database Client->>HTTPAdapter: HTTP Request HTTPAdapter->>InputPort: Call Method InputPort->>Core: Execute Business Logic Core->>OutputPort: Request Data OutputPort->>DBAdapter: Interface Call DBAdapter->>Database: SQL Query Database-->>DBAdapter: Data DBAdapter-->>OutputPort: Domain Object OutputPort-->>Core: Result Core-->>InputPort: Response InputPort-->>HTTPAdapter: DTO HTTPAdapter-->>Client: HTTP Response Real-World Use Cases API Gateways: Multiple protocols (REST, gRPC, GraphQL) with same core logic Multi-tenant Applications: Different adapters for different tenants Legacy System Integration: Adapter for each legacy system Testing-Critical Systems: Easy mocking of all external dependencies Cloud-Native Applications: Easy switching between cloud providers Evolutionary Architecture: System that needs to adapt over time Project Structure ├── cmd/ │ ├── http/ │ │ └── main.go # HTTP server entry point │ └── cli/ │ └── main.go # CLI entry point ├── internal/ │ ├── core/ │ │ ├── domain/ # Domain entities and value objects │ │ │ ├── user.go │ │ │ └── order.go │ │ ├── port/ # Ports (interfaces) │ │ │ ├── input.go # Primary/Driving ports │ │ │ └── output.go # Secondary/Driven ports │ │ └── service/ # Business logic │ │ └── user_service.go │ └── adapter/ │ ├── input/ # Primary/Driving adapters │ │ ├── http/ │ │ │ └── user_handler.go │ │ └── grpc/ │ │ └── user_server.go │ └── output/ # Secondary/Driven adapters │ ├── persistence/ │ │ └── postgres_user_repository.go │ └── notification/ │ └── email_service.go └── go.mod Core Domain Layer Domain Entities package domain import ( "errors" "time" ) // User represents a user entity in the domain type User struct { ID string Email Email Name string Status UserStatus CreatedAt time.Time UpdatedAt time.Time } // Email is a value object representing an email address type Email struct { value string } // NewEmail creates a new email value object with validation func NewEmail(email string) (Email, error) { if !isValidEmail(email) { return Email{}, errors.New("invalid email format") } return Email{value: email}, nil } // String returns the email string value func (e Email) String() string { return e.value } // UserStatus represents the status of a user type UserStatus string const ( UserStatusActive UserStatus = "active" UserStatusInactive UserStatus = "inactive" UserStatusSuspended UserStatus = "suspended" ) // NewUser creates a new user with business rule validation func NewUser(email, name string) (*User, error) { emailVO, err := NewEmail(email) if err != nil { return nil, err } if name == "" { return nil, errors.New("name cannot be empty") } return &User{ Email: emailVO, Name: name, Status: UserStatusActive, CreatedAt: time.Now(), UpdatedAt: time.Now(), }, nil } // Activate activates the user func (u *User) Activate() { u.Status = UserStatusActive u.UpdatedAt = time.Now() } // Suspend suspends the user func (u *User) Suspend() { u.Status = UserStatusSuspended u.UpdatedAt = time.Now() } // IsActive returns true if user is active func (u *User) IsActive() bool { return u.Status == UserStatusActive } func isValidEmail(email string) bool { // Simplified validation return len(email) > 3 && contains(email, "@") } func contains(s, substr string) bool { for i := 0; i <= len(s)-len(substr); i++ { if s[i:i+len(substr)] == substr { return true } } return false } // Order represents an order in the domain type Order struct { ID string UserID string Items []OrderItem Total Money Status OrderStatus CreatedAt time.Time } // OrderItem represents a line item in an order type OrderItem struct { ProductID string Quantity int Price Money } // OrderStatus represents the status of an order type OrderStatus string const ( OrderStatusPending OrderStatus = "pending" OrderStatusConfirmed OrderStatus = "confirmed" OrderStatusShipped OrderStatus = "shipped" OrderStatusDelivered OrderStatus = "delivered" OrderStatusCancelled OrderStatus = "cancelled" ) // Money represents monetary value type Money struct { Amount int64 Currency string } // NewMoney creates a new money value object func NewMoney(amount int64, currency string) Money { return Money{ Amount: amount, Currency: currency, } } // Add adds two money values func (m Money) Add(other Money) (Money, error) { if m.Currency != other.Currency { return Money{}, errors.New("cannot add different currencies") } return Money{ Amount: m.Amount + other.Amount, Currency: m.Currency, }, nil } Ports (Interfaces) Primary Ports (Driving/Input) package port import ( "context" "myapp/internal/core/domain" ) // UserService defines the input port for user operations // This is what drives the application (called by adapters) type UserService interface { CreateUser(ctx context.Context, email, name string) (*domain.User, error) GetUser(ctx context.Context, id string) (*domain.User, error) UpdateUser(ctx context.Context, id, email, name string) (*domain.User, error) DeleteUser(ctx context.Context, id string) error ListUsers(ctx context.Context, offset, limit int) ([]*domain.User, error) ActivateUser(ctx context.Context, id string) error SuspendUser(ctx context.Context, id string) error } // OrderService defines the input port for order operations type OrderService interface { CreateOrder(ctx context.Context, userID string, items []OrderItemInput) (*domain.Order, error) GetOrder(ctx context.Context, id string) (*domain.Order, error) CancelOrder(ctx context.Context, id string) error GetUserOrders(ctx context.Context, userID string) ([]*domain.Order, error) } // OrderItemInput represents input for creating an order item type OrderItemInput struct { ProductID string Quantity int } Secondary Ports (Driven/Output) package port import ( "context" "myapp/internal/core/domain" ) // UserRepository defines the output port for user persistence // This is driven by the application (implemented by adapters) type UserRepository interface { Save(ctx context.Context, user *domain.User) error FindByID(ctx context.Context, id string) (*domain.User, error) FindByEmail(ctx context.Context, email string) (*domain.User, error) Update(ctx context.Context, user *domain.User) error Delete(ctx context.Context, id string) error FindAll(ctx context.Context, offset, limit int) ([]*domain.User, error) } // OrderRepository defines the output port for order persistence type OrderRepository interface { Save(ctx context.Context, order *domain.Order) error FindByID(ctx context.Context, id string) (*domain.Order, error) FindByUserID(ctx context.Context, userID string) ([]*domain.Order, error) Update(ctx context.Context, order *domain.Order) error } // NotificationService defines the output port for notifications type NotificationService interface { SendWelcomeEmail(ctx context.Context, user *domain.User) error SendOrderConfirmation(ctx context.Context, order *domain.Order) error SendOrderCancellation(ctx context.Context, order *domain.Order) error } // IDGenerator defines the output port for ID generation type IDGenerator interface { GenerateID() string } // EventPublisher defines the output port for publishing domain events type EventPublisher interface { PublishUserCreated(ctx context.Context, user *domain.User) error PublishUserActivated(ctx context.Context, user *domain.User) error PublishOrderPlaced(ctx context.Context, order *domain.Order) error } Core Service (Business Logic) package service import ( "context" "errors" "fmt" "myapp/internal/core/domain" "myapp/internal/core/port" ) // userService implements the UserService port type userService struct { repo port.UserRepository notification port.NotificationService idGen port.IDGenerator eventPub port.EventPublisher } // NewUserService creates a new user service func NewUserService( repo port.UserRepository, notification port.NotificationService, idGen port.IDGenerator, eventPub port.EventPublisher, ) port.UserService { return &userService{ repo: repo, notification: notification, idGen: idGen, eventPub: eventPub, } } // CreateUser creates a new user func (s *userService) CreateUser(ctx context.Context, email, name string) (*domain.User, error) { // Check if user already exists existingUser, err := s.repo.FindByEmail(ctx, email) if err == nil && existingUser != nil { return nil, errors.New("user already exists") } // Create user entity with business rules user, err := domain.NewUser(email, name) if err != nil { return nil, fmt.Errorf("failed to create user: %w", err) } // Generate ID user.ID = s.idGen.GenerateID() // Save user if err := s.repo.Save(ctx, user); err != nil { return nil, fmt.Errorf("failed to save user: %w", err) } // Send notification (best effort, don't fail on error) if err := s.notification.SendWelcomeEmail(ctx, user); err != nil { // Log error but don't fail fmt.Printf("failed to send welcome email: %v\n", err) } // Publish event if err := s.eventPub.PublishUserCreated(ctx, user); err != nil { // Log error but don't fail fmt.Printf("failed to publish user created event: %v\n", err) } return user, nil } // GetUser retrieves a user by ID func (s *userService) GetUser(ctx context.Context, id string) (*domain.User, error) { user, err := s.repo.FindByID(ctx, id) if err != nil { return nil, fmt.Errorf("failed to get user: %w", err) } if user == nil { return nil, errors.New("user not found") } return user, nil } // UpdateUser updates a user func (s *userService) UpdateUser(ctx context.Context, id, email, name string) (*domain.User, error) { user, err := s.repo.FindByID(ctx, id) if err != nil { return nil, fmt.Errorf("user not found: %w", err) } // Update email if changed if email != "" && email != user.Email.String() { newEmail, err := domain.NewEmail(email) if err != nil { return nil, err } // Check if new email is already taken existingUser, _ := s.repo.FindByEmail(ctx, email) if existingUser != nil && existingUser.ID != id { return nil, errors.New("email already exists") } user.Email = newEmail } // Update name if changed if name != "" && name != user.Name { user.Name = name } // Save updated user if err := s.repo.Update(ctx, user); err != nil { return nil, fmt.Errorf("failed to update user: %w", err) } return user, nil } // DeleteUser deletes a user func (s *userService) DeleteUser(ctx context.Context, id string) error { user, err := s.repo.FindByID(ctx, id) if err != nil { return errors.New("user not found") } if !user.IsActive() { return errors.New("cannot delete inactive user") } return s.repo.Delete(ctx, id) } // ListUsers lists users with pagination func (s *userService) ListUsers(ctx context.Context, offset, limit int) ([]*domain.User, error) { if limit <= 0 || limit > 100 { limit = 20 } if offset < 0 { offset = 0 } return s.repo.FindAll(ctx, offset, limit) } // ActivateUser activates a user func (s *userService) ActivateUser(ctx context.Context, id string) error { user, err := s.repo.FindByID(ctx, id) if err != nil { return errors.New("user not found") } user.Activate() if err := s.repo.Update(ctx, user); err != nil { return fmt.Errorf("failed to activate user: %w", err) } // Publish event if err := s.eventPub.PublishUserActivated(ctx, user); err != nil { fmt.Printf("failed to publish user activated event: %v\n", err) } return nil } // SuspendUser suspends a user func (s *userService) SuspendUser(ctx context.Context, id string) error { user, err := s.repo.FindByID(ctx, id) if err != nil { return errors.New("user not found") } user.Suspend() return s.repo.Update(ctx, user) } Primary Adapters (Driving/Input) HTTP Adapter package http import ( "encoding/json" "net/http" "myapp/internal/core/port" ) // UserHandler is the HTTP adapter for user operations type UserHandler struct { service port.UserService } // NewUserHandler creates a new HTTP user handler func NewUserHandler(service port.UserService) *UserHandler { return &UserHandler{service: service} } // CreateUserRequest represents the HTTP request type CreateUserRequest struct { Email string `json:"email"` Name string `json:"name"` } // UserResponse represents the HTTP response type UserResponse struct { ID string `json:"id"` Email string `json:"email"` Name string `json:"name"` Status string `json:"status"` CreatedAt string `json:"created_at"` } // CreateUser handles POST /users func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) { var req CreateUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respondError(w, http.StatusBadRequest, "invalid request") return } user, err := h.service.CreateUser(r.Context(), req.Email, req.Name) if err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } respondJSON(w, http.StatusCreated, toUserResponse(user)) } // GetUser handles GET /users/{id} func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") user, err := h.service.GetUser(r.Context(), id) if err != nil { respondError(w, http.StatusNotFound, "user not found") return } respondJSON(w, http.StatusOK, toUserResponse(user)) } // UpdateUser handles PUT /users/{id} func (h *UserHandler) UpdateUser(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") var req CreateUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respondError(w, http.StatusBadRequest, "invalid request") return } user, err := h.service.UpdateUser(r.Context(), id, req.Email, req.Name) if err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } respondJSON(w, http.StatusOK, toUserResponse(user)) } // DeleteUser handles DELETE /users/{id} func (h *UserHandler) DeleteUser(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if err := h.service.DeleteUser(r.Context(), id); err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } w.WriteHeader(http.StatusNoContent) } // Helper functions func toUserResponse(user *domain.User) UserResponse { return UserResponse{ ID: user.ID, Email: user.Email.String(), Name: user.Name, Status: string(user.Status), CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z"), } } func respondJSON(w http.ResponseWriter, status int, data interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(data) } func respondError(w http.ResponseWriter, status int, message string) { respondJSON(w, status, map[string]string{"error": message}) } Secondary Adapters (Driven/Output) Database Adapter package persistence import ( "context" "database/sql" "errors" "time" "myapp/internal/core/domain" "myapp/internal/core/port" ) // postgresUserRepository implements the UserRepository port type postgresUserRepository struct { db *sql.DB } // NewPostgresUserRepository creates a new Postgres user repository func NewPostgresUserRepository(db *sql.DB) port.UserRepository { return &postgresUserRepository{db: db} } // Save saves a user func (r *postgresUserRepository) Save(ctx context.Context, user *domain.User) error { query := ` INSERT INTO users (id, email, name, status, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6) ` _, err := r.db.ExecContext( ctx, query, user.ID, user.Email.String(), user.Name, user.Status, user.CreatedAt, user.UpdatedAt, ) return err } // FindByID finds a user by ID func (r *postgresUserRepository) FindByID(ctx context.Context, id string) (*domain.User, error) { query := ` SELECT id, email, name, status, created_at, updated_at FROM users WHERE id = $1 ` var ( userID string email string name string status string createdAt time.Time updatedAt time.Time ) err := r.db.QueryRowContext(ctx, query, id).Scan( &userID, &email, &name, &status, &createdAt, &updatedAt, ) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } emailVO, _ := domain.NewEmail(email) return &domain.User{ ID: userID, Email: emailVO, Name: name, Status: domain.UserStatus(status), CreatedAt: createdAt, UpdatedAt: updatedAt, }, nil } // FindByEmail finds a user by email func (r *postgresUserRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) { query := ` SELECT id, email, name, status, created_at, updated_at FROM users WHERE email = $1 ` var ( userID string emailStr string name string status string createdAt time.Time updatedAt time.Time ) err := r.db.QueryRowContext(ctx, query, email).Scan( &userID, &emailStr, &name, &status, &createdAt, &updatedAt, ) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } emailVO, _ := domain.NewEmail(emailStr) return &domain.User{ ID: userID, Email: emailVO, Name: name, Status: domain.UserStatus(status), CreatedAt: createdAt, UpdatedAt: updatedAt, }, nil } // Update updates a user func (r *postgresUserRepository) Update(ctx context.Context, user *domain.User) error { query := ` UPDATE users SET email = $1, name = $2, status = $3, updated_at = $4 WHERE id = $5 ` result, err := r.db.ExecContext( ctx, query, user.Email.String(), user.Name, user.Status, time.Now(), user.ID, ) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { return errors.New("user not found") } return nil } // Delete deletes a user func (r *postgresUserRepository) Delete(ctx context.Context, id string) error { query := `DELETE FROM users WHERE id = $1` result, err := r.db.ExecContext(ctx, query, id) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { return errors.New("user not found") } return nil } // FindAll finds all users with pagination func (r *postgresUserRepository) FindAll(ctx context.Context, offset, limit int) ([]*domain.User, error) { query := ` SELECT id, email, name, status, created_at, updated_at FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2 ` rows, err := r.db.QueryContext(ctx, query, limit, offset) if err != nil { return nil, err } defer rows.Close() var users []*domain.User for rows.Next() { var ( userID string email string name string status string createdAt time.Time updatedAt time.Time ) if err := rows.Scan(&userID, &email, &name, &status, &createdAt, &updatedAt); err != nil { return nil, err } emailVO, _ := domain.NewEmail(email) users = append(users, &domain.User{ ID: userID, Email: emailVO, Name: name, Status: domain.UserStatus(status), CreatedAt: createdAt, UpdatedAt: updatedAt, }) } return users, nil } Email Notification Adapter package notification import ( "context" "fmt" "log" "myapp/internal/core/domain" "myapp/internal/core/port" ) // emailService implements the NotificationService port type emailService struct { smtpHost string smtpPort int fromAddr string } // NewEmailService creates a new email notification service func NewEmailService(smtpHost string, smtpPort int, fromAddr string) port.NotificationService { return &emailService{ smtpHost: smtpHost, smtpPort: smtpPort, fromAddr: fromAddr, } } // SendWelcomeEmail sends a welcome email to a new user func (s *emailService) SendWelcomeEmail(ctx context.Context, user *domain.User) error { // In production, use actual email service log.Printf("Sending welcome email to %s", user.Email.String()) return s.sendEmail(ctx, user.Email.String(), "Welcome!", "Welcome to our service!") } // SendOrderConfirmation sends an order confirmation email func (s *emailService) SendOrderConfirmation(ctx context.Context, order *domain.Order) error { log.Printf("Sending order confirmation for order %s", order.ID) return nil } // SendOrderCancellation sends an order cancellation email func (s *emailService) SendOrderCancellation(ctx context.Context, order *domain.Order) error { log.Printf("Sending order cancellation for order %s", order.ID) return nil } func (s *emailService) sendEmail(ctx context.Context, to, subject, body string) error { // Simulate email sending fmt.Printf("Email sent to %s: %s\n", to, subject) return nil } Main Application with Dependency Injection package main import ( "database/sql" "log" "net/http" _ "github.com/lib/pq" "github.com/google/uuid" "myapp/internal/adapter/input/http" "myapp/internal/adapter/output/notification" "myapp/internal/adapter/output/persistence" "myapp/internal/core/port" "myapp/internal/core/service" ) // UUIDGenerator implements IDGenerator port type UUIDGenerator struct{} func (g *UUIDGenerator) GenerateID() string { return uuid.New().String() } // MockEventPublisher implements EventPublisher port type MockEventPublisher struct{} func (p *MockEventPublisher) PublishUserCreated(ctx context.Context, user *domain.User) error { log.Printf("Event: User created - %s", user.ID) return nil } func (p *MockEventPublisher) PublishUserActivated(ctx context.Context, user *domain.User) error { log.Printf("Event: User activated - %s", user.ID) return nil } func (p *MockEventPublisher) PublishOrderPlaced(ctx context.Context, order *domain.Order) error { log.Printf("Event: Order placed - %s", order.ID) return nil } func main() { // Initialize database db, err := sql.Open("postgres", "postgres://user:pass@localhost/hexarch?sslmode=disable") if err != nil { log.Fatal(err) } defer db.Close() // Initialize secondary adapters (driven/output) userRepo := persistence.NewPostgresUserRepository(db) emailService := notification.NewEmailService("smtp.example.com", 587, "[email protected]") idGen := &UUIDGenerator{} eventPub := &MockEventPublisher{} // Initialize core service userService := service.NewUserService(userRepo, emailService, idGen, eventPub) // Initialize primary adapters (driving/input) httpHandler := httpAdapter.NewUserHandler(userService) // Setup routes mux := http.NewServeMux() mux.HandleFunc("POST /users", httpHandler.CreateUser) mux.HandleFunc("GET /users/{id}", httpHandler.GetUser) mux.HandleFunc("PUT /users/{id}", httpHandler.UpdateUser) mux.HandleFunc("DELETE /users/{id}", httpHandler.DeleteUser) // Start server log.Println("Server starting on :8080") if err := http.ListenAndServe(":8080", mux); err != nil { log.Fatal(err) } } Testing with Mock Adapters package service_test import ( "context" "testing" "myapp/internal/core/domain" "myapp/internal/core/service" ) // Mock repository type mockUserRepository struct { users map[string]*domain.User } func newMockUserRepository() *mockUserRepository { return &mockUserRepository{ users: make(map[string]*domain.User), } } func (m *mockUserRepository) Save(ctx context.Context, user *domain.User) error { m.users[user.ID] = user return nil } func (m *mockUserRepository) FindByID(ctx context.Context, id string) (*domain.User, error) { return m.users[id], nil } func (m *mockUserRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) { for _, user := range m.users { if user.Email.String() == email { return user, nil } } return nil, nil } // Mock notification service type mockNotificationService struct { sentEmails []string } func (m *mockNotificationService) SendWelcomeEmail(ctx context.Context, user *domain.User) error { m.sentEmails = append(m.sentEmails, user.Email.String()) return nil } // Mock ID generator type mockIDGenerator struct { id int } func (m *mockIDGenerator) GenerateID() string { m.id++ return fmt.Sprintf("user-%d", m.id) } // Mock event publisher type mockEventPublisher struct{} func (m *mockEventPublisher) PublishUserCreated(ctx context.Context, user *domain.User) error { return nil } func TestCreateUser(t *testing.T) { // Arrange repo := newMockUserRepository() notif := &mockNotificationService{} idGen := &mockIDGenerator{} eventPub := &mockEventPublisher{} service := service.NewUserService(repo, notif, idGen, eventPub) // Act user, err := service.CreateUser(context.Background(), "[email protected]", "Test User") // Assert if err != nil { t.Fatalf("expected no error, got %v", err) } if user.Email.String() != "[email protected]" { t.Errorf("expected email [email protected], got %s", user.Email.String()) } if len(notif.sentEmails) != 1 { t.Errorf("expected 1 welcome email, got %d", len(notif.sentEmails)) } } Best Practices Port Definition: Define ports (interfaces) in the core domain Adapter Independence: Adapters should not know about each other Domain First: Design domain model before thinking about adapters Single Responsibility: Each adapter handles one external concern Configuration Injection: Inject configuration into adapters, not core Error Handling: Let domain define error types Testing: Use mock adapters for testing core logic Common Pitfalls Adapter Coupling: Adapters depending on each other directly Leaky Abstractions: Infrastructure details leaking into core Anemic Ports: Ports that are too thin or just data transfer Adapter in Core: Importing adapter packages in core Forgetting Symmetry: Treating primary and secondary adapters differently Over-abstraction: Creating too many small ports When to Use Hexagonal Architecture Use When: ...

    January 11, 2025 · 17 min · Rafiul Alam

    The Two-Tier API Strategy: Why You Need Both REST and RPC (and How to Manage It)

    The API Dilemma: REST vs RPC? For years, teams have debated REST vs RPC as if they were mutually exclusive choices. The truth? You need both. Modern applications benefit from a two-tier API strategy that leverages REST for external clients and RPC for internal services. This isn’t about choosing sides-it’s about using the right tool for each job. Understanding the Two Tiers Tier 1: REST for External APIs (The Public Face) Use REST when: ...

    January 10, 2025 · 12 min · Rafiul Alam