DDD, Hexagonal Architecture, BDD & TDD for Agile Teams: The Enterprise Playbook
Architecture

DDD, Hexagonal Architecture, BDD & TDD for Agile Teams: The Enterprise Playbook

Master Domain Driven Design, Hexagonal Architecture, BDD, and TDD in Agile Scrum teams. Real enterprise case studies, Go, React, and Flutter implementations with practical tools and workflows.

Por Omar Flores · Actualizado: February 18, 2026
#go #golang #ddd #domain-driven-design #hexagonal-architecture #bdd #tdd #agile #scrum #react #flutter #testing #architecture #enterprise #bounded-context #ports-adapters #cqrs #event-sourcing #clean-architecture #backend #frontend #quality #team-workflow

DDD, Hexagonal Architecture, BDD & TDD for Agile Teams: The Enterprise Playbook

The Company That Built a Tower of Cards

In 2018, a mid-size e-commerce company had a problem.

They had been growing fast. Really fast. Ten new developers in eight months. Three acquired startups integrated into the codebase. A new mobile app built in React Native. A backend in Node.js that had started as a weekend prototype and somehow survived five years.

Their codebase was a monolith of approximately 400,000 lines. Nobody fully understood it. The senior developer who wrote most of it had left the previous year. The documentation was three README files last updated in 2016.

Every new feature took four to six weeks. Every release broke something unrelated. The QA team ran manual tests for three days before every deployment. The CEO was furious. The engineering team was demoralized.

They hired a consultant. The consultant spent two weeks reading code. Then she said something nobody expected.

“Your problem is not the technology. Your problem is that you do not speak the same language as your business. Your code has no relationship with what your company actually does.”

The team was confused.

The consultant explained.

“You have a table called order_items. But your sales team calls them line products. Your payment team calls them charges. Your shipping team calls them parcels. Same thing. Four names. Four places in the code. Four sets of bugs from misunderstandings.”

She pointed at a service file called UserService.java. It was 3,200 lines long. It handled registration, authentication, shipping addresses, loyalty points, wishlist management, customer support tickets, and invoice generation.

“This is not a User Service. This is a graveyard of features nobody dared to separate.”

What happened next changed the company.

Over eighteen months, with a Scrum team of eight developers, they rebuilt the system using Domain Driven Design, Hexagonal Architecture, BDD, and TDD. Feature lead time dropped from six weeks to four days. Release failures dropped by 94%. The QA cycle went from three days to forty minutes.

This guide teaches you exactly what they did.


Part 1 - Domain Driven Design: Speaking the Language of the Business

What DDD Actually Is

Domain Driven Design was invented by Eric Evans and published in the now-famous blue book in 2003. In that book, Evans made a simple but radical argument.

The most important part of software is not the database schema. It is not the framework. It is not the technology stack.

The most important part is the domain: the business problem you are solving and the rules that govern it.

If your code does not reflect the domain, every conversation between developers and business people becomes a translation exercise. And every translation introduces errors.

DDD is the practice of building software where the code reflects the domain so closely that developers and business people can talk about the same thing using the same words.

That sounds simple. It is not. But it is worth it.

The Ubiquitous Language

The foundation of DDD is the Ubiquitous Language.

Ubiquitous means everywhere. The language should be the same in conversations, in documents, in code, in tests, in database tables. Everywhere.

This language is developed collaboratively. Developers and business experts sit together. They argue about words. They draw diagrams. They tell stories. They discover that what they thought was one concept is actually three. They find that what they called two different things is actually the same.

Here is a real example from the e-commerce company.

The business expert says: “When a customer orders something, we fulfill it.”

The developer says: “So Order and Fulfillment are different concepts?”

The business expert thinks. “Yes. An Order is the customer’s intent to buy. A Fulfillment is when we act on that intent. You can have an Order that hasn’t been fulfilled yet.”

The developer: “What about when the order is partially shipped? You sent two items but one is out of stock.”

The business expert: “That’s a Partial Fulfillment. The original Order is still open until we fulfill all line items or cancel the remainder.”

This conversation produced the Ubiquitous Language for this part of the system. And notice: the words are precise. They are specific. Each word has one meaning. Nobody says “process the transaction” because “process” means different things to different people and “transaction” is ambiguous.

In code, this looks like:

// Good: reflects the Ubiquitous Language
type Order struct {
    ID          OrderID
    CustomerID  CustomerID
    LineItems   []LineItem
    Status      OrderStatus
    PlacedAt    time.Time
}

type Fulfillment struct {
    ID          FulfillmentID
    OrderID     OrderID
    LineItems   []FulfillmentLineItem
    Status      FulfillmentStatus
    FulfilledAt time.Time
}

// Bad: generic names that mean nothing
type Record struct {
    Data interface{}
}

type Handler struct {
    Process func(interface{}) interface{}
}

When a new developer joins the team and reads Order and Fulfillment, they understand what those things are. They might not know every detail, but the words are not lying to them.

Entities, Value Objects, and Aggregates

DDD introduces a vocabulary for the building blocks of your domain.

Entities are objects that have an identity. Two Order objects are different if they have different IDs, even if every other field is identical. An entity changes over time. An Order placed today is the same Order next week, even if its status changed.

// Order is an Entity. Its identity is its OrderID.
type Order struct {
    id         OrderID     // Identity. Never changes.
    customerID CustomerID
    lineItems  []LineItem
    status     OrderStatus
    placedAt   time.Time
}

// ID returns the order's identity.
// We expose it, but we do not expose a setter. Identity is immutable.
func (o *Order) ID() OrderID {
    return o.id
}

// AddLineItem adds a product to the order.
// Note: this method enforces the business rule that you cannot add items to a confirmed order.
func (o *Order) AddLineItem(item LineItem) error {
    if o.status != OrderStatusDraft {
        return ErrOrderAlreadyConfirmed
    }
    o.lineItems = append(o.lineItems, item)
    return nil
}

Value Objects are objects that have no identity. Their equality is based on their values. Two Money objects representing “100 USD” are equal because they represent the same concept, not because they are the same instance. Value objects are immutable. You do not modify a value object. You replace it.

// Money is a Value Object. It represents a currency amount.
// Two Money values are equal if amount and currency are equal.
type Money struct {
    amount   int64  // Store in smallest currency unit (cents) to avoid floating point issues
    currency string // ISO 4217 currency code: "USD", "MXN", "EUR"
}

// NewMoney creates a Money value object.
// The constructor validates the inputs, enforcing invariants.
func NewMoney(amount int64, currency string) (Money, error) {
    if amount < 0 {
        return Money{}, ErrNegativeAmount
    }
    if len(currency) != 3 {
        return Money{}, ErrInvalidCurrency
    }
    return Money{amount: amount, currency: currency}, nil
}

// Add returns a new Money that is the sum of two Money values.
// Notice: it returns a NEW value. It does not modify the receiver.
func (m Money) Add(other Money) (Money, error) {
    if m.currency != other.currency {
        return Money{}, ErrCurrencyMismatch
    }
    return Money{amount: m.amount + other.amount, currency: m.currency}, nil
}

// Equal implements value equality.
func (m Money) Equal(other Money) bool {
    return m.amount == other.amount && m.currency == other.currency
}

// String formats the money for display.
func (m Money) String() string {
    return fmt.Sprintf("%s %.2f", m.currency, float64(m.amount)/100)
}

Aggregates are clusters of entities and value objects that are treated as a single unit for data changes. An aggregate has a root entity, called the Aggregate Root. All access to the aggregate goes through the root. Nothing outside the aggregate holds a direct reference to an internal entity.

// Order is the Aggregate Root for the ordering context.
// All mutations to the order and its line items go through Order's methods.
// External code never holds a pointer to a LineItem directly.
type Order struct {
    id         OrderID
    customerID CustomerID
    lineItems  []LineItem    // Internal entities. Not exposed directly.
    total      Money
    status     OrderStatus
    version    int           // For optimistic concurrency control
    events     []DomainEvent // Uncommitted domain events
    placedAt   time.Time
}

// LineItem is an entity within the Order aggregate.
// It cannot exist outside the Order. Order is responsible for its lifecycle.
type LineItem struct {
    id        LineItemID
    productID ProductID
    quantity  int
    unitPrice Money
}

// Confirm changes the order status to confirmed.
// Business rule: an order must have at least one line item to be confirmed.
func (o *Order) Confirm() error {
    if len(o.lineItems) == 0 {
        return ErrEmptyOrder
    }
    if o.status != OrderStatusDraft {
        return ErrOrderAlreadyConfirmed
    }

    o.status = OrderStatusConfirmed

    // Record a domain event. This is how the aggregate tells the world something happened.
    o.events = append(o.events, OrderConfirmed{
        OrderID:    o.id,
        CustomerID: o.customerID,
        Total:      o.total,
        ConfirmedAt: time.Now(),
    })

    return nil
}

// PullEvents returns uncommitted events and clears the internal list.
// Called by the repository after persisting the aggregate.
func (o *Order) PullEvents() []DomainEvent {
    events := make([]DomainEvent, len(o.events))
    copy(events, o.events)
    o.events = nil
    return events
}

Domain Events

Domain Events represent something that happened in the domain. They are named in the past tense. They are immutable records of fact.

OrderConfirmed. PaymentProcessed. CustomerRegistered. ItemShipped.

Domain events are powerful for three reasons.

First: they make the domain behavior explicit and auditable. Second: they allow other bounded contexts to react to things that happened without tight coupling. Third: they are the foundation of Event Sourcing, which we will touch on later.

// DomainEvent is the base interface all events implement.
type DomainEvent interface {
    EventName() string
    OccurredAt() time.Time
}

// OrderConfirmed is a domain event.
// It carries all the information needed to understand what happened.
type OrderConfirmed struct {
    OrderID     OrderID
    CustomerID  CustomerID
    Total       Money
    LineItems   []LineItemSnapshot // A snapshot of the items at confirmation time
    ConfirmedAt time.Time
}

func (e OrderConfirmed) EventName() string    { return "order.confirmed" }
func (e OrderConfirmed) OccurredAt() time.Time { return e.ConfirmedAt }

// PaymentProcessed is raised by the payment context when a charge succeeds.
type PaymentProcessed struct {
    PaymentID   PaymentID
    OrderID     OrderID
    Amount      Money
    Method      string
    ProcessedAt time.Time
}

func (e PaymentProcessed) EventName() string    { return "payment.processed" }
func (e PaymentProcessed) OccurredAt() time.Time { return e.ProcessedAt }

Bounded Contexts: Drawing the Lines

One of the most important ideas in DDD is the Bounded Context.

A Bounded Context is a boundary within which a particular domain model applies. Inside the boundary, every term has one clear meaning. Outside the boundary, the same word might mean something different.

Think about the word “Customer.”

In the Sales bounded context, a Customer is someone who has placed orders. Sales cares about purchase history, spending patterns, and lifetime value.

In the Support bounded context, a Customer is someone who has submitted tickets. Support cares about open issues, communication history, and satisfaction scores.

In the Shipping bounded context, a Customer is just a set of delivery addresses and contact information.

Same word. Three different models. Three different bounded contexts.

The mistake most teams make is trying to have one Customer model that satisfies all three. This model becomes bloated, full of nullable fields, and impossible to reason about. Every change to satisfy one context risks breaking the others.

The solution: three separate models. Three separate services (or modules). The Customer in the Sales context has nothing to do with the Customer in the Shipping context, except that they share the same CustomerID as a reference.

┌─────────────────────────┐    ┌──────────────────────────┐
│    Sales Context         │    │   Support Context         │
│                          │    │                           │
│  Customer {              │    │  Customer {               │
│    id                    │    │    id                     │
│    name                  │    │    name                   │
│    email                 │    │    email                  │
│    orders[]              │    │    tickets[]              │
│    totalSpent            │    │    satisfactionScore      │
│    segment               │    │    lastContactDate        │
│  }                       │    │  }                        │
└─────────────────────────┘    └──────────────────────────┘

           Both share CustomerID as a reference.
           But they are separate models with separate concerns.

The Context Map: How Bounded Contexts Relate

Bounded Contexts do not live in isolation. They need to exchange information. The Context Map describes how they relate.

Some common patterns:

Shared Kernel: Two contexts share a small portion of the domain model. Changes must be agreed upon by both teams.

Customer-Supplier: One context (supplier) provides data to another (customer). The customer adapts to the supplier’s model.

Anti-Corruption Layer (ACL): A translation layer that protects one context from another’s model. Useful when integrating with external systems or legacy code.

Published Language: A well-documented shared model (usually based on open standards) that multiple contexts use.

In Go, the ACL pattern looks like this:

// The Shipping context needs customer delivery information.
// The Customer data lives in the Identity context.
// The ACL translates between the two models.

// ShippingCustomer is the model the Shipping context cares about.
// It only contains fields relevant to shipping.
type ShippingCustomer struct {
    CustomerID       string
    DefaultAddress   Address
    AlternateAddresses []Address
    PhoneNumber      string
}

// IdentityServiceACL is the Anti-Corruption Layer.
// It calls the Identity service and translates the response to the Shipping model.
type IdentityServiceACL struct {
    identityClient IdentityServiceClient // Calls the external Identity service
}

// GetShippingCustomer fetches customer data and translates it
// to the Shipping bounded context's model.
func (acl *IdentityServiceACL) GetShippingCustomer(ctx context.Context, id string) (*ShippingCustomer, error) {
    // Call the Identity service. This returns THEIR model.
    identityCustomer, err := acl.identityClient.GetCustomer(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("identity service error: %w", err)
    }

    // Translate. The Shipping context does not know or care about
    // the Identity service's full Customer model.
    return &ShippingCustomer{
        CustomerID: identityCustomer.UUID,
        DefaultAddress: Address{
            Street:  identityCustomer.PrimaryAddress.Line1,
            City:    identityCustomer.PrimaryAddress.City,
            Country: identityCustomer.PrimaryAddress.CountryCode,
            ZipCode: identityCustomer.PrimaryAddress.PostalCode,
        },
        PhoneNumber: identityCustomer.ContactPhone,
    }, nil
}

The Shipping context never imports the Identity service’s domain types. If the Identity service changes its model, only the ACL needs to be updated. The Shipping domain is protected.


Part 2 - Hexagonal Architecture: Building Software You Can Actually Test

The Core Idea

Hexagonal Architecture was described by Alistair Cockburn in 2005. He called it “Ports and Adapters.” The shape is a hexagon because Cockburn wanted to convey that there are many sides, not just a front and a back.

The central insight is this: your domain logic should have no dependency on anything external. Not the database. Not the HTTP framework. Not the message queue. Not the file system.

The domain logic lives in the center. Everything else connects to it through ports (interfaces) and adapters (implementations).

This might sound abstract. Let us make it concrete.

Before hexagonal architecture, typical code looks like this:

// BAD: The business logic depends directly on the infrastructure.
// This function cannot be tested without a real database.
func ConfirmOrder(db *sql.DB, orderID string) error {
    row := db.QueryRow("SELECT * FROM orders WHERE id = $1", orderID)
    // ... parse row into order ...

    if len(order.LineItems) == 0 {
        return errors.New("cannot confirm empty order")
    }

    _, err := db.Exec("UPDATE orders SET status = 'confirmed' WHERE id = $1", orderID)
    return err
}

To test ConfirmOrder, you need a running PostgreSQL database. The database needs a schema. The schema needs migrations. The migration needs to run. Your test is now an integration test even though you only wanted to test the business rule “cannot confirm empty order.”

With hexagonal architecture:

// GOOD: The domain logic depends on an abstraction (port), not a concrete implementation.
// This function can be tested with a simple mock.

// OrderRepository is the Port. It defines the contract for data access.
// The domain does not know how data is stored. It only knows this interface.
type OrderRepository interface {
    GetByID(ctx context.Context, id OrderID) (*Order, error)
    Save(ctx context.Context, order *Order) error
}

// ConfirmOrderUseCase is the application service.
// It orchestrates the domain logic.
type ConfirmOrderUseCase struct {
    orders OrderRepository // Depends on the port, not the adapter.
    events EventPublisher  // Another port.
}

// Execute runs the use case.
// Pure business logic. No database. No HTTP. No framework.
func (uc *ConfirmOrderUseCase) Execute(ctx context.Context, orderID OrderID) error {
    order, err := uc.orders.GetByID(ctx, orderID)
    if err != nil {
        return fmt.Errorf("order not found: %w", err)
    }

    if err := order.Confirm(); err != nil {
        return err // The domain rule is enforced here
    }

    if err := uc.orders.Save(ctx, order); err != nil {
        return fmt.Errorf("failed to save order: %w", err)
    }

    // Publish domain events
    for _, event := range order.PullEvents() {
        if err := uc.events.Publish(ctx, event); err != nil {
            return fmt.Errorf("failed to publish event: %w", err)
        }
    }

    return nil
}

Now the test looks like this:

func TestConfirmOrderUseCase_Success(t *testing.T) {
    // A mock repository. No database needed.
    repo := &mockOrderRepository{
        orders: map[OrderID]*Order{
            "order-1": newOrderWithItems("order-1", "customer-1"),
        },
    }
    publisher := &mockEventPublisher{}

    useCase := &ConfirmOrderUseCase{orders: repo, events: publisher}

    err := useCase.Execute(context.Background(), "order-1")

    if err != nil {
        t.Fatalf("expected no error, got: %v", err)
    }

    order := repo.orders["order-1"]
    if order.Status() != OrderStatusConfirmed {
        t.Errorf("expected order to be confirmed, got: %v", order.Status())
    }

    if len(publisher.published) != 1 {
        t.Errorf("expected 1 event, got %d", len(publisher.published))
    }
}

Fast. No setup. No database. Tests the business logic in isolation.

The Layers in Detail

Hexagonal architecture has three layers.

Domain Layer — The innermost circle. Contains entities, value objects, aggregates, domain events, and domain services. Has zero dependencies on anything outside itself. This is the heart of the application.

Application Layer — The second circle. Contains use cases (also called application services or interactors). Orchestrates the domain. Defines ports (interfaces). Depends on the domain layer only.

Infrastructure Layer — The outermost circle. Contains adapters. Implementations of the ports. The HTTP handlers, database repositories, message queue publishers, external API clients, file system readers. Depends on the application and domain layers.

┌─────────────────────────────────────────────────────────────┐
│  Infrastructure Layer                                        │
│  (HTTP, PostgreSQL, Redis, Kafka, External APIs)            │
│                                                              │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  Application Layer                                   │   │
│  │  (Use Cases, Ports/Interfaces)                       │   │
│  │                                                       │   │
│  │  ┌─────────────────────────────────────────────┐    │   │
│  │  │  Domain Layer                                │    │   │
│  │  │  (Entities, Value Objects, Domain Events)    │    │   │
│  │  │  NO EXTERNAL DEPENDENCIES                   │    │   │
│  │  └─────────────────────────────────────────────┘    │   │
│  └─────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘

Dependency rule: arrows point inward. Inner layers know nothing of outer layers.

The Full Project Structure in Go

ecommerce/

├── domain/                          # Domain Layer - no external dependencies
│   ├── order/
│   │   ├── order.go                 # Order aggregate
│   │   ├── line_item.go             # LineItem entity
│   │   ├── order_status.go          # OrderStatus value object
│   │   ├── order_id.go              # OrderID value object
│   │   ├── events.go                # OrderConfirmed, OrderCancelled, etc.
│   │   └── errors.go                # Domain errors
│   ├── customer/
│   │   ├── customer.go
│   │   └── events.go
│   └── shared/
│       ├── money.go                 # Money value object (used across contexts)
│       └── address.go               # Address value object

├── application/                     # Application Layer
│   ├── order/
│   │   ├── ports.go                 # OrderRepository, EventPublisher interfaces
│   │   ├── confirm_order.go         # ConfirmOrder use case
│   │   ├── cancel_order.go          # CancelOrder use case
│   │   ├── place_order.go           # PlaceOrder use case
│   │   └── get_order.go             # GetOrder query
│   └── customer/
│       ├── ports.go
│       └── register_customer.go

├── infrastructure/                  # Infrastructure Layer (adapters)
│   ├── persistence/
│   │   ├── postgres/
│   │   │   ├── order_repository.go  # PostgreSQL adapter for OrderRepository
│   │   │   └── migrations/
│   │   └── memory/
│   │       └── order_repository.go  # In-memory adapter (used in tests)
│   ├── messaging/
│   │   ├── kafka/
│   │   │   └── event_publisher.go   # Kafka adapter for EventPublisher
│   │   └── memory/
│   │       └── event_publisher.go   # In-memory adapter (used in tests)
│   ├── http/
│   │   ├── handlers/
│   │   │   ├── order_handler.go     # HTTP adapter for order use cases
│   │   │   └── customer_handler.go
│   │   └── middleware/
│   │       └── auth.go
│   └── external/
│       └── payment_gateway.go       # Adapter for external payment API

├── features/                        # BDD feature files
│   ├── order/
│   │   ├── confirm_order.feature
│   │   └── place_order.feature
│   └── customer/
│       └── register_customer.feature

├── cmd/
│   └── server/
│       └── main.go

├── docker-compose.yml
├── Makefile
├── go.mod
└── go.sum

Notice how the structure reflects the architecture. A new developer can look at the directory tree and understand: domain is the core, application orchestrates, infrastructure connects to the outside world.

Implementing the PostgreSQL Adapter

The PostgreSQL adapter implements the OrderRepository port:

// infrastructure/persistence/postgres/order_repository.go

package postgres

import (
    "context"
    "database/sql"
    "encoding/json"
    "fmt"

    "ecommerce/domain/order"
    "ecommerce/domain/shared"
)

// PostgresOrderRepository is the PostgreSQL adapter for order.OrderRepository.
// It knows about SQL. The domain does not.
type PostgresOrderRepository struct {
    db *sql.DB
}

func NewPostgresOrderRepository(db *sql.DB) *PostgresOrderRepository {
    return &PostgresOrderRepository{db: db}
}

// GetByID loads an order from PostgreSQL and reconstructs the domain aggregate.
func (r *PostgresOrderRepository) GetByID(ctx context.Context, id order.OrderID) (*order.Order, error) {
    const query = `
        SELECT
            o.id, o.customer_id, o.status, o.total_amount, o.total_currency,
            o.placed_at, o.version,
            COALESCE(
                json_agg(
                    json_build_object(
                        'id', li.id,
                        'product_id', li.product_id,
                        'quantity', li.quantity,
                        'unit_amount', li.unit_price_amount,
                        'unit_currency', li.unit_price_currency
                    )
                ) FILTER (WHERE li.id IS NOT NULL),
                '[]'
            ) AS line_items
        FROM orders o
        LEFT JOIN order_line_items li ON li.order_id = o.id
        WHERE o.id = $1
        GROUP BY o.id
    `

    var row struct {
        ID             string
        CustomerID     string
        Status         string
        TotalAmount    int64
        TotalCurrency  string
        PlacedAt       sql.NullTime
        Version        int
        LineItemsJSON  []byte
    }

    err := r.db.QueryRowContext(ctx, query, string(id)).Scan(
        &row.ID, &row.CustomerID, &row.Status,
        &row.TotalAmount, &row.TotalCurrency,
        &row.PlacedAt, &row.Version, &row.LineItemsJSON,
    )
    if err == sql.ErrNoRows {
        return nil, fmt.Errorf("%w: %s", order.ErrOrderNotFound, id)
    }
    if err != nil {
        return nil, fmt.Errorf("query error: %w", err)
    }

    // Reconstruct the domain aggregate from the database row.
    // This is the repository's job: translate between storage and domain models.
    return r.reconstruct(row, row.LineItemsJSON)
}

// Save persists an order aggregate to PostgreSQL.
// Uses optimistic concurrency to prevent lost updates.
func (r *PostgresOrderRepository) Save(ctx context.Context, o *order.Order) error {
    tx, err := r.db.BeginTx(ctx, nil)
    if err != nil {
        return fmt.Errorf("begin transaction: %w", err)
    }
    defer tx.Rollback() // Rollback is a no-op if Commit was called.

    // Upsert the order. Use optimistic locking with the version field.
    const upsertOrder = `
        INSERT INTO orders (id, customer_id, status, total_amount, total_currency, placed_at, version)
        VALUES ($1, $2, $3, $4, $5, $6, 1)
        ON CONFLICT (id) DO UPDATE SET
            status         = EXCLUDED.status,
            total_amount   = EXCLUDED.total_amount,
            total_currency = EXCLUDED.total_currency,
            version        = orders.version + 1
        WHERE orders.version = $7
    `

    result, err := tx.ExecContext(ctx, upsertOrder,
        string(o.ID()), string(o.CustomerID()),
        string(o.Status()), o.Total().Amount(), o.Total().Currency(),
        o.PlacedAt(), o.Version(),
    )
    if err != nil {
        return fmt.Errorf("upsert order: %w", err)
    }

    rowsAffected, _ := result.RowsAffected()
    if rowsAffected == 0 {
        return order.ErrConcurrencyConflict // Someone else updated this order
    }

    // Delete and re-insert line items (simple strategy for small aggregates).
    if _, err := tx.ExecContext(ctx, "DELETE FROM order_line_items WHERE order_id = $1", string(o.ID())); err != nil {
        return fmt.Errorf("delete line items: %w", err)
    }

    for _, item := range o.LineItems() {
        const insertItem = `
            INSERT INTO order_line_items (id, order_id, product_id, quantity, unit_price_amount, unit_price_currency)
            VALUES ($1, $2, $3, $4, $5, $6)
        `
        if _, err := tx.ExecContext(ctx, insertItem,
            string(item.ID()), string(o.ID()), string(item.ProductID()),
            item.Quantity(), item.UnitPrice().Amount(), item.UnitPrice().Currency(),
        ); err != nil {
            return fmt.Errorf("insert line item: %w", err)
        }
    }

    return tx.Commit()
}

// reconstruct builds a domain Order from raw database data.
// This is private to the repository. The domain does not know about this.
func (r *PostgresOrderRepository) reconstruct(row struct {
    ID            string
    CustomerID    string
    Status        string
    TotalAmount   int64
    TotalCurrency string
    PlacedAt      sql.NullTime
    Version       int
}, lineItemsJSON []byte) (*order.Order, error) {
    total, err := shared.NewMoney(row.TotalAmount, row.TotalCurrency)
    if err != nil {
        return nil, fmt.Errorf("reconstruct money: %w", err)
    }

    var rawItems []struct {
        ID           string `json:"id"`
        ProductID    string `json:"product_id"`
        Quantity     int    `json:"quantity"`
        UnitAmount   int64  `json:"unit_amount"`
        UnitCurrency string `json:"unit_currency"`
    }
    if err := json.Unmarshal(lineItemsJSON, &rawItems); err != nil {
        return nil, fmt.Errorf("parse line items: %w", err)
    }

    // Use the domain's reconstitution constructor.
    // This bypasses validation (the data is already valid, it came from our database).
    return order.Reconstitute(order.ReconstitutionParams{
        ID:         order.OrderID(row.ID),
        CustomerID: order.CustomerID(row.CustomerID),
        Status:     order.OrderStatus(row.Status),
        Total:      total,
        PlacedAt:   row.PlacedAt.Time,
        Version:    row.Version,
        // ... map line items ...
    })
}

Part 3 - BDD Meets DDD: Writing Features in the Domain Language

The Alignment

When you combine BDD with DDD, something beautiful happens.

Your Gherkin scenarios use the Ubiquitous Language. Your step definitions map to use cases. Your use cases call the domain. The domain speaks the same language as the business.

The chain is complete: from the product manager’s mouth to the database, every layer speaks the same language.

This is not a coincidence. It is the design.

Look at this BDD scenario:

# features/order/place_order.feature

Feature: Placing an Order
  As a customer
  I want to place an order for products I've selected
  So that I can receive them at my address

  Background:
    Given the product "Rust Programming Book" with price "45.00 USD" is available
    And customer "alice@example.com" has a registered account

  Scenario: Successfully placing an order with valid items
    Given customer "alice@example.com" has added the following items to their cart:
      | product                  | quantity |
      | Rust Programming Book    | 2        |
    When customer "alice@example.com" places the order
    Then the order should be created with status "draft"
    And the order total should be "90.00 USD"
    And an "OrderPlaced" event should have been published

  Scenario: Cannot place an empty order
    Given customer "alice@example.com" has an empty cart
    When customer "alice@example.com" places the order
    Then the operation should fail with error "cannot place order without items"

  Scenario: Insufficient stock prevents placing order
    Given the product "Rust Programming Book" only has 1 unit in stock
    And customer "alice@example.com" has added "2" units of "Rust Programming Book"
    When customer "alice@example.com" places the order
    Then the operation should fail with error "insufficient stock for product: Rust Programming Book"

Notice: every word in that scenario exists in the Ubiquitous Language. “Customer,” “Order,” “Draft status,” “OrderPlaced event.” No technical jargon. No database tables. No function names.

A product manager reading this knows exactly what the system should do. They can verify it. They can add scenarios. They are a participant in the specification, not a spectator.

Step Definitions That Mirror Use Cases

The step definitions connect Gherkin to the application layer:

// features/steps/order_steps.go

package steps

import (
    "context"
    "fmt"

    "github.com/cucumber/godog"
    "ecommerce/application/order"
    "ecommerce/domain/shared"
    "ecommerce/infrastructure/persistence/memory"
)

type orderFeatureContext struct {
    // Repositories (in-memory adapters for tests)
    orderRepo    *memory.OrderRepository
    productRepo  *memory.ProductRepository
    customerRepo *memory.CustomerRepository

    // Use cases
    placeOrder    *order.PlaceOrderUseCase
    confirmOrder  *order.ConfirmOrderUseCase

    // Scenario state
    lastOrderID  order.OrderID
    lastError    error
    cartItems    []order.CartItem
    currentUser  string
    publishedEvents []string
}

func InitializeOrderScenario(sc *godog.ScenarioContext) {
    ctx := &orderFeatureContext{}

    sc.Before(func(goCtx context.Context, scenario *godog.Scenario) (context.Context, error) {
        // Fresh state for every scenario.
        ctx.orderRepo    = memory.NewOrderRepository()
        ctx.productRepo  = memory.NewProductRepository()
        ctx.customerRepo = memory.NewCustomerRepository()
        eventPub := memory.NewEventPublisher()

        ctx.placeOrder   = order.NewPlaceOrderUseCase(ctx.orderRepo, ctx.productRepo, eventPub)
        ctx.confirmOrder = order.NewConfirmOrderUseCase(ctx.orderRepo, eventPub)
        ctx.cartItems    = nil
        ctx.lastError    = nil
        ctx.publishedEvents = eventPub.Published // Share the slice

        return goCtx, nil
    })

    sc.Step(`^the product "([^"]*)" with price "([^"]*)" is available$`, ctx.productIsAvailable)
    sc.Step(`^customer "([^"]*)" has a registered account$`, ctx.customerHasAccount)
    sc.Step(`^customer "([^"]*)" has added the following items to their cart:$`, ctx.customerAddsItems)
    sc.Step(`^customer "([^"]*)" has an empty cart$`, ctx.customerHasEmptyCart)
    sc.Step(`^the product "([^"]*)" only has (\d+) unit in stock$`, ctx.productHasLimitedStock)
    sc.Step(`^customer "([^"]*)" has added "(\d+)" units of "([^"]*)"$`, ctx.customerAddsSpecificQuantity)
    sc.Step(`^customer "([^"]*)" places the order$`, ctx.customerPlacesOrder)
    sc.Step(`^the order should be created with status "([^"]*)"$`, ctx.orderShouldHaveStatus)
    sc.Step(`^the order total should be "([^"]*)"$`, ctx.orderTotalShouldBe)
    sc.Step(`^an "([^"]*)" event should have been published$`, ctx.eventShouldHaveBeenPublished)
    sc.Step(`^the operation should fail with error "([^"]*)"$`, ctx.operationShouldFailWith)
}

func (ctx *orderFeatureContext) customerPlacesOrder(email string) error {
    customer, err := ctx.customerRepo.GetByEmail(context.Background(), email)
    if err != nil {
        return fmt.Errorf("customer not found: %w", err)
    }

    // Call the use case. This is the actual application layer.
    orderID, err := ctx.placeOrder.Execute(context.Background(), order.PlaceOrderCommand{
        CustomerID: customer.ID(),
        Items:      ctx.cartItems,
    })

    ctx.lastOrderID = orderID
    ctx.lastError   = err // Store error for assertion in "Then" steps
    return nil // Do not fail here; the "Then" step will assert
}

func (ctx *orderFeatureContext) orderShouldHaveStatus(expectedStatus string) error {
    if ctx.lastError != nil {
        return fmt.Errorf("order was not placed: %v", ctx.lastError)
    }

    o, err := ctx.orderRepo.GetByID(context.Background(), ctx.lastOrderID)
    if err != nil {
        return err
    }

    if string(o.Status()) != expectedStatus {
        return fmt.Errorf("expected status %q, got %q", expectedStatus, o.Status())
    }
    return nil
}

func (ctx *orderFeatureContext) orderTotalShouldBe(expectedTotal string) error {
    if ctx.lastError != nil {
        return fmt.Errorf("order was not placed: %v", ctx.lastError)
    }

    o, err := ctx.orderRepo.GetByID(context.Background(), ctx.lastOrderID)
    if err != nil {
        return err
    }

    // Parse "90.00 USD" into a Money value object for comparison
    expectedMoney, err := parseMoney(expectedTotal)
    if err != nil {
        return err
    }

    if !o.Total().Equal(expectedMoney) {
        return fmt.Errorf("expected total %v, got %v", expectedMoney, o.Total())
    }
    return nil
}

func (ctx *orderFeatureContext) operationShouldFailWith(expectedError string) error {
    if ctx.lastError == nil {
        return fmt.Errorf("expected operation to fail with %q, but it succeeded", expectedError)
    }
    if ctx.lastError.Error() != expectedError {
        return fmt.Errorf("expected error %q, got %q", expectedError, ctx.lastError.Error())
    }
    return nil
}

Each step definition is a thin translation layer. The real work happens in the use case. The step just calls it and stores the result.


Part 4 - The PlaceOrder Use Case: DDD + TDD in Action

The Use Case

The use case is where the domain logic is orchestrated. It is the application layer’s contribution. It does not contain business rules. It delegates to the domain.

// application/order/place_order.go

package order

import (
    "context"
    "fmt"

    "ecommerce/domain/order"
    "ecommerce/domain/product"
    "ecommerce/domain/shared"
)

// PlaceOrderCommand contains all the data needed to place an order.
// It is a plain data struct. No methods. No behavior.
type PlaceOrderCommand struct {
    CustomerID order.CustomerID
    Items      []CartItem
}

// CartItem represents a product and quantity in the command.
type CartItem struct {
    ProductID order.ProductID
    Quantity  int
}

// PlaceOrderUseCase orchestrates placing an order.
type PlaceOrderUseCase struct {
    orders   OrderRepository
    products ProductRepository
    events   EventPublisher
}

func NewPlaceOrderUseCase(orders OrderRepository, products ProductRepository, events EventPublisher) *PlaceOrderUseCase {
    return &PlaceOrderUseCase{
        orders:   orders,
        products: products,
        events:   events,
    }
}

// Execute runs the use case.
// It validates commands, coordinates the domain, and persists the result.
func (uc *PlaceOrderUseCase) Execute(ctx context.Context, cmd PlaceOrderCommand) (order.OrderID, error) {
    // Step 1: Validate the command itself.
    if len(cmd.Items) == 0 {
        return "", order.ErrEmptyOrder
    }

    // Step 2: Load product information and verify stock.
    // We need product data to build the order. Products live in a different aggregate.
    var lineItems []order.LineItemInput
    for _, item := range cmd.Items {
        p, err := uc.products.GetByID(ctx, item.ProductID)
        if err != nil {
            return "", fmt.Errorf("product %s not found: %w", item.ProductID, err)
        }

        if !p.HasStock(item.Quantity) {
            return "", fmt.Errorf("insufficient stock for product: %s", p.Name())
        }

        lineItems = append(lineItems, order.LineItemInput{
            ProductID: item.ProductID,
            Quantity:  item.Quantity,
            UnitPrice: p.Price(),
        })
    }

    // Step 3: Create the order aggregate using the domain factory.
    // The domain is responsible for the business logic of creation.
    newOrder, err := order.NewOrder(order.NewOrderParams{
        CustomerID: cmd.CustomerID,
        LineItems:  lineItems,
    })
    if err != nil {
        return "", fmt.Errorf("could not create order: %w", err)
    }

    // Step 4: Persist the aggregate.
    if err := uc.orders.Save(ctx, newOrder); err != nil {
        return "", fmt.Errorf("failed to save order: %w", err)
    }

    // Step 5: Publish domain events.
    for _, event := range newOrder.PullEvents() {
        if err := uc.events.Publish(ctx, event); err != nil {
            // Log but don't fail — the order was saved successfully.
            // In production you would use an outbox pattern here.
            _ = fmt.Errorf("warning: failed to publish event %s: %w", event.EventName(), err)
        }
    }

    return newOrder.ID(), nil
}

TDD for the Use Case

Now we write TDD unit tests for the use case. These tests run without any database, HTTP, or external service.

// application/order/place_order_test.go

package order_test

import (
    "context"
    "testing"

    apporder "ecommerce/application/order"
    "ecommerce/domain/order"
    "ecommerce/domain/shared"
)

// ─── Test: Successful placement ─────────────────────────────────────────────

func TestPlaceOrderUseCase_Success(t *testing.T) {
    // Arrange
    orders    := newMockOrderRepository()
    products  := newMockProductRepository()
    events    := newMockEventPublisher()

    // Set up a product in the mock repo
    products.add(&mockProduct{
        id:       "prod-1",
        name:     "Rust Programming Book",
        price:    mustMoney(4500, "USD"), // 45.00 USD
        stock:    10,
    })

    useCase := apporder.NewPlaceOrderUseCase(orders, products, events)

    // Act
    orderID, err := useCase.Execute(context.Background(), apporder.PlaceOrderCommand{
        CustomerID: "customer-1",
        Items: []apporder.CartItem{
            {ProductID: "prod-1", Quantity: 2},
        },
    })

    // Assert
    if err != nil {
        t.Fatalf("expected no error, got: %v", err)
    }
    if orderID == "" {
        t.Fatal("expected a non-empty order ID")
    }

    // Verify the order was saved
    saved, err := orders.GetByID(context.Background(), orderID)
    if err != nil {
        t.Fatalf("expected order to be saved: %v", err)
    }

    // Verify the total (2 * 45.00 = 90.00 USD)
    expectedTotal := mustMoney(9000, "USD")
    if !saved.Total().Equal(expectedTotal) {
        t.Errorf("expected total %v, got %v", expectedTotal, saved.Total())
    }

    // Verify an event was published
    if len(events.published) != 1 {
        t.Errorf("expected 1 event published, got %d", len(events.published))
    }
    if events.published[0].EventName() != "order.placed" {
        t.Errorf("expected event 'order.placed', got %s", events.published[0].EventName())
    }
}

// ─── Test: Empty order ───────────────────────────────────────────────────────

func TestPlaceOrderUseCase_EmptyOrder(t *testing.T) {
    orders    := newMockOrderRepository()
    products  := newMockProductRepository()
    events    := newMockEventPublisher()

    useCase := apporder.NewPlaceOrderUseCase(orders, products, events)

    _, err := useCase.Execute(context.Background(), apporder.PlaceOrderCommand{
        CustomerID: "customer-1",
        Items:      nil, // Empty
    })

    if err == nil {
        t.Fatal("expected error for empty order, got nil")
    }
    if err != order.ErrEmptyOrder {
        t.Errorf("expected ErrEmptyOrder, got: %v", err)
    }

    // Verify nothing was saved
    if orders.saveCount != 0 {
        t.Errorf("expected no orders saved, got %d", orders.saveCount)
    }
}

// ─── Test: Insufficient stock ────────────────────────────────────────────────

func TestPlaceOrderUseCase_InsufficientStock(t *testing.T) {
    orders   := newMockOrderRepository()
    products := newMockProductRepository()
    events   := newMockEventPublisher()

    // Product with only 1 item in stock
    products.add(&mockProduct{
        id:    "prod-1",
        name:  "Rust Programming Book",
        price: mustMoney(4500, "USD"),
        stock: 1, // Only 1 in stock
    })

    useCase := apporder.NewPlaceOrderUseCase(orders, products, events)

    _, err := useCase.Execute(context.Background(), apporder.PlaceOrderCommand{
        CustomerID: "customer-1",
        Items: []apporder.CartItem{
            {ProductID: "prod-1", Quantity: 2}, // Requesting 2, only 1 available
        },
    })

    if err == nil {
        t.Fatal("expected error for insufficient stock, got nil")
    }
    if err.Error() != "insufficient stock for product: Rust Programming Book" {
        t.Errorf("unexpected error: %v", err)
    }
}

// ─── Test Table: Multiple scenarios at once ───────────────────────────────────

func TestPlaceOrderUseCase_Table(t *testing.T) {
    testCases := []struct {
        name          string
        items         []apporder.CartItem
        productStock  int
        wantErr       bool
        wantErrMsg    string
    }{
        {
            name:         "two items, sufficient stock",
            items:        []apporder.CartItem{{ProductID: "prod-1", Quantity: 2}},
            productStock: 10,
            wantErr:      false,
        },
        {
            name:         "zero items",
            items:        nil,
            productStock: 10,
            wantErr:      true,
            wantErrMsg:   "cannot place order without items",
        },
        {
            name:         "exact stock",
            items:        []apporder.CartItem{{ProductID: "prod-1", Quantity: 5}},
            productStock: 5,
            wantErr:      false,
        },
        {
            name:         "one over stock",
            items:        []apporder.CartItem{{ProductID: "prod-1", Quantity: 6}},
            productStock: 5,
            wantErr:      true,
            wantErrMsg:   "insufficient stock for product: Test Product",
        },
    }

    for _, tc := range testCases {
        t.Run(tc.name, func(t *testing.T) {
            products := newMockProductRepository()
            products.add(&mockProduct{
                id: "prod-1", name: "Test Product",
                price: mustMoney(1000, "USD"), stock: tc.productStock,
            })

            useCase := apporder.NewPlaceOrderUseCase(
                newMockOrderRepository(),
                products,
                newMockEventPublisher(),
            )

            _, err := useCase.Execute(context.Background(), apporder.PlaceOrderCommand{
                CustomerID: "customer-1",
                Items:      tc.items,
            })

            if tc.wantErr && err == nil {
                t.Errorf("expected error %q, got nil", tc.wantErrMsg)
            }
            if !tc.wantErr && err != nil {
                t.Errorf("expected no error, got: %v", err)
            }
            if tc.wantErr && err != nil && err.Error() != tc.wantErrMsg {
                t.Errorf("expected error %q, got %q", tc.wantErrMsg, err.Error())
            }
        })
    }
}

The tests are fast. They never touch a database. They test real business logic.


Part 5 - Scrum + DDD: How the Team Works Together

The Sprint Cycle Inside DDD Boundaries

In a DDD-focused Scrum team, the sprint ceremony language changes.

In a typical Scrum team: “We need to add a field to the user table and update the API.”

In a DDD Scrum team: “We need to capture the customer’s preferred shipping address in the Customer aggregate and expose it through the Order context’s checkout flow.”

Sounds more verbose. But it is more precise. Everyone understands what is being changed, in which bounded context, and why.

Story Mapping With Domain Events

Story mapping is a technique for understanding user journeys. When you combine it with domain events, it becomes incredibly powerful.

Here is how the e-commerce team did their story mapping:

They put all the domain events on sticky notes, in the order a customer would experience them:

[CustomerRegistered] → [ProductAdded to Cart] → [OrderPlaced] → [PaymentProcessed] →
[OrderConfirmed] → [FulfillmentStarted] → [ItemPicked] → [PackageShipped] → [OrderDelivered]

Each domain event becomes the heart of a user story.

“As a customer, I want to be notified when my package ships” is a story about the [PackageShipped] event.

“As a warehouse operator, I want to see which orders need to be packed today” is a story about querying [OrderConfirmed] events.

The domain events tell the story of the business. The user stories fill in the details.

Sprint Planning: Bounded Context Awareness

One of the most common mistakes in Scrum teams is planning sprints that create cross-context dependencies.

Team A is building the Orders context. Team B is building the Payments context. Team A’s sprint depends on Team B finishing their work first.

Now you have a blocked sprint. Everyone is waiting. Velocity drops. People work on other things. Context switches. Focus disappears.

The solution: design your sprints around bounded contexts. Each team owns one or more bounded contexts completely. Stories within a bounded context can be built independently.

Cross-context communication happens through domain events. Team A publishes an OrderConfirmed event. Team B subscribes to it. Neither team knows how the other is implemented. Neither is blocked by the other.

In practice, this means:

Sprint Kickoff Questions:

  • Which bounded context does this story belong to?
  • Does this story require cross-context data? If yes, can we use an event instead of a direct call?
  • Are we creating a new aggregate or modifying an existing one?
  • What domain events will this story produce?

These questions take five minutes in planning. They save days of debugging later.

Definition of Done in DDD + BDD

The Definition of Done for a DDD team should include:

  • The Ubiquitous Language is used in the code (entity names, method names, error messages match the domain language)
  • The feature has a Gherkin scenario written with the product owner
  • Unit tests cover all domain logic (TDD)
  • BDD scenarios pass
  • No business logic lives in the infrastructure layer (no SQL queries in use cases)
  • Domain events are published correctly
  • The aggregate invariants are tested
  • The adapter (repository) has integration tests

This Definition of Done seems demanding. But it produces code that is consistently high quality, regardless of which developer wrote it.


Part 6 - Real Enterprise Case Studies

Case Study 1: The Bank That Couldn’t Ship Features

A mid-size bank in 2019 had a lending division. The lending software was a 15-year-old Java application. Adding a new loan product type took three to four months, minimum. Regulatory changes required emergency patches that broke other features.

The core problem: there was one Loan table. It had 87 columns. Some columns were only used for certain loan types. Some columns had names that contradicted each other depending on the loan type. The domain model had collapsed under the weight of ten years of special cases.

They applied DDD.

First step: Event Storming. They brought together developers, business analysts, compliance officers, and a domain expert in lending. Over two days, they mapped every domain event in the lending lifecycle.

What they discovered surprised everyone.

What they called a “Loan” was actually three different things:

  1. A Loan Application — the customer’s request. Governed by underwriting rules.
  2. A Loan Agreement — the legal contract. Governed by compliance rules.
  3. A Loan Account — the ongoing financial relationship. Governed by payment rules.

These were three separate aggregates in three separate bounded contexts. They had been crammed into one table for fifteen years.

They rebuilt with hexagonal architecture. Each context had its own domain model, its own database schema, its own team.

The Loan Application context: a developer could make a change and deploy it without touching the Loan Account context at all.

Result after twelve months: time to market for new loan products dropped from sixteen weeks to three. Defect rate dropped by 78%. Two compliance officers who had been manually reviewing code changes before every release said “we can actually read this now. The code matches our documentation.”

That last sentence is the goal of DDD.

Case Study 2: The Logistics Company With Three Teams and Zero Coordination

A logistics company had three teams building a platform:

  • Team Alpha: Order management (taking delivery orders from clients)
  • Team Beta: Dispatch (assigning drivers to orders)
  • Team Gamma: Tracking (GPS tracking and customer-facing status)

They had a shared database. All three teams wrote to the same tables. Every sprint, one team’s migration broke another team’s queries.

They implemented DDD + hexagonal architecture with message-based communication between contexts.

Team Alpha published DeliveryOrderPlaced events. Team Beta subscribed and assigned drivers, publishing DriverAssigned events. Team Gamma subscribed to both and updated the tracking view.

Each team had their own database. No shared tables. No migrations stepping on each other.

Communication happened through events on a Kafka topic. The schema was versioned. New fields were additive. Breaking changes required a new event type.

Result: Team Beta could deploy independently on any day of the week. They were no longer blocked by Team Alpha’s sprint cycle. Feature delivery velocity increased by 60%. Deployment frequency went from once a month to multiple times per week.

Case Study 3: Healthcare — Where Wrong Is Not an Option

A hospital system needed to rebuild its patient intake process. The stakes were different from e-commerce. A wrong calculation in a loan application costs money. A wrong calculation in medication dosage costs lives.

The DDD approach here was defensive.

Every domain concept had extremely strong invariants. A Prescription aggregate could not be created without a valid PatientID, a valid MedicationID, a prescribing DoctorID with the right license type, and a dosage that passed rules from a medical rules engine.

// domain/prescription/prescription.go

// Prescription is an Aggregate. Its invariants are medical safety rules.
type Prescription struct {
    id          PrescriptionID
    patientID   PatientID
    doctorID    DoctorID
    medicationID MedicationID
    dosage      Dosage
    frequency   Frequency
    startDate   time.Time
    endDate     *time.Time
    status      PrescriptionStatus
    events      []DomainEvent
}

// NewPrescription creates a prescription only if all safety rules pass.
// This function is the gatekeeper. It either produces a valid prescription
// or it returns a descriptive error. No partial prescriptions.
func NewPrescription(params NewPrescriptionParams, rules MedicalRules) (*Prescription, error) {
    // Rule 1: Patient must exist and be active.
    if !params.Patient.IsActive() {
        return nil, ErrInactivePatient
    }

    // Rule 2: Doctor must be licensed for this medication class.
    if !rules.DoctorCanPrescribe(params.Doctor, params.Medication) {
        return nil, ErrDoctorNotLicensed{
            DoctorID:   params.Doctor.ID(),
            MedClass:   params.Medication.Class(),
        }
    }

    // Rule 3: Dosage must be within safe range for this patient (weight, age, allergies).
    if err := rules.ValidateDosage(params.Patient, params.Medication, params.Dosage); err != nil {
        return nil, fmt.Errorf("dosage validation failed: %w", err)
    }

    // Rule 4: Check for drug interactions with current medications.
    interactions, err := rules.CheckInteractions(params.Patient, params.Medication)
    if err != nil {
        return nil, fmt.Errorf("interaction check failed: %w", err)
    }
    if len(interactions) > 0 {
        return nil, ErrDrugInteraction{Interactions: interactions}
    }

    p := &Prescription{
        id:           newPrescriptionID(),
        patientID:    params.Patient.ID(),
        doctorID:     params.Doctor.ID(),
        medicationID: params.Medication.ID(),
        dosage:       params.Dosage,
        frequency:    params.Frequency,
        startDate:    params.StartDate,
        status:       PrescriptionStatusActive,
    }

    p.events = append(p.events, PrescriptionCreated{
        PrescriptionID: p.id,
        PatientID:      p.patientID,
        DoctorID:       p.doctorID,
        MedicationID:   p.medicationID,
        CreatedAt:      time.Now(),
    })

    return p, nil
}

The BDD scenarios for this system were written with the compliance team and reviewed by the chief medical officer:

Feature: Creating a Prescription
  As a licensed doctor
  I want to create a prescription for a patient
  So that the patient receives appropriate medication

  Scenario: Valid prescription by licensed doctor
    Given doctor "Dr. Elena Vasquez" is licensed to prescribe "Schedule III" medications
    And patient "John Smith" is active with weight "80 kg" and no known allergies
    When Dr. Vasquez prescribes "Amoxicillin 500mg" 3 times daily for 7 days to John Smith
    Then the prescription should be created with status "active"
    And a "PrescriptionCreated" audit event should be recorded

  Scenario: Doctor not licensed for medication class
    Given doctor "Dr. Bob Chen" is licensed to prescribe "Schedule V" medications only
    And patient "Jane Doe" is active
    When Dr. Chen attempts to prescribe "OxyContin" (Schedule II) to Jane Doe
    Then the prescription should be rejected with error "doctor not licensed for medication class: Schedule II"
    And no prescription record should exist

  Scenario: Drug interaction detected
    Given patient "Tom Wilson" is currently taking "Warfarin"
    When a doctor prescribes "Aspirin" to Tom Wilson
    Then the prescription should be rejected with error "drug interaction detected: Warfarin + Aspirin increases bleeding risk"

These scenarios served double duty. They were acceptance criteria for development. They were also documentation for the compliance audit. The auditors could read the feature files and verify the system behaved according to medical regulations.


Part 7 - The Frontend as an Adapter: React and Flutter

The Core Idea

In hexagonal architecture, the frontend is an adapter. Not the core of the system. An adapter.

The frontend presents domain information to the user. It translates user actions into commands for the application. It subscribes to domain events to update itself.

The domain does not know the frontend exists. The frontend knows about the application layer’s input/output contracts. Nothing more.

This has a powerful implication: you can completely replace the frontend (from React to Flutter, from mobile to desktop) without changing a single line of domain code.

React as an Adapter: BDD for the Frontend

The React application talks to the backend via an API. From the React perspective, the API is the port. The HTTP client is the adapter.

You can apply BDD to the React frontend too.

The tool of choice: Playwright (for E2E) and React Testing Library + Vitest (for component tests).

// features/checkout/place-order.spec.ts (Playwright E2E)

import { test, expect } from "@playwright/test";

test.describe("Placing an Order", () => {
  test.beforeEach(async ({ page }) => {
    // Seed the database via API or use fixtures
    await page.request.post("/api/test/seed", {
      data: {
        products: [
          {
            id: "prod-1",
            name: "Rust Programming Book",
            price: 4500,
            stock: 10,
          },
        ],
        customers: [{ email: "alice@example.com", password: "secret" }],
      },
    });
  });

  test("successfully places an order from the cart", async ({ page }) => {
    // Given: Alice is logged in
    await page.goto("/login");
    await page.fill('[data-testid="email"]', "alice@example.com");
    await page.fill('[data-testid="password"]', "secret");
    await page.click('[data-testid="login-button"]');

    // And: She navigated to a product
    await page.goto("/products/prod-1");

    // When: She adds it to the cart and checks out
    await page.click('[data-testid="add-to-cart"]');
    await page.goto("/cart");
    await page.click('[data-testid="place-order-button"]');

    // Then: She sees the order confirmation
    await expect(
      page.locator('[data-testid="order-confirmation"]'),
    ).toBeVisible();
    await expect(page.locator('[data-testid="order-status"]')).toHaveText(
      "Order Placed",
    );
    await expect(page.locator('[data-testid="order-total"]')).toHaveText(
      "USD 45.00",
    );
  });

  test("shows error when cart is empty", async ({ page }) => {
    await page.goto("/login");
    await page.fill('[data-testid="email"]', "alice@example.com");
    await page.fill('[data-testid="password"]', "secret");
    await page.click('[data-testid="login-button"]');

    await page.goto("/cart");
    await page.click('[data-testid="place-order-button"]');

    await expect(page.locator('[data-testid="error-message"]')).toHaveText(
      "Your cart is empty. Add some products before placing an order.",
    );
  });
});

For component-level tests with React Testing Library:

// components/CartSummary/CartSummary.test.tsx

import { render, screen, fireEvent } from '@testing-library/react';
import { CartSummary } from './CartSummary';

// A simple mock for the cart service. Frontend adapter pattern.
const mockPlaceOrder = jest.fn();
const mockCartService = {
  getItems: jest.fn(),
  placeOrder: mockPlaceOrder,
};

describe('CartSummary', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  test('displays line items and total correctly', () => {
    mockCartService.getItems.mockReturnValue([
      { id: '1', name: 'Rust Programming Book', quantity: 2, unitPrice: 4500, currency: 'USD' },
    ]);

    render(<CartSummary cartService={mockCartService} />);

    expect(screen.getByText('Rust Programming Book')).toBeInTheDocument();
    expect(screen.getByText('Qty: 2')).toBeInTheDocument();
    expect(screen.getByText('Total: USD 90.00')).toBeInTheDocument();
  });

  test('calls placeOrder when button is clicked', () => {
    mockCartService.getItems.mockReturnValue([
      { id: '1', name: 'Book', quantity: 1, unitPrice: 1000, currency: 'USD' },
    ]);
    mockPlaceOrder.mockResolvedValue({ orderId: 'order-123', status: 'draft' });

    render(<CartSummary cartService={mockCartService} />);
    fireEvent.click(screen.getByTestId('place-order-button'));

    expect(mockPlaceOrder).toHaveBeenCalledTimes(1);
  });

  test('shows empty cart message when no items', () => {
    mockCartService.getItems.mockReturnValue([]);

    render(<CartSummary cartService={mockCartService} />);

    expect(screen.getByText('Your cart is empty')).toBeInTheDocument();
    expect(screen.getByTestId('place-order-button')).toBeDisabled();
  });
});

Flutter as an Adapter: BDD for Mobile

Flutter follows the same pattern but uses different tools. The test tool of choice is the integration_test package for E2E and flutter_test for unit and widget tests.

// features/checkout/place_order_test.dart (Flutter integration test)

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:ecommerce_app/main.dart' as app;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('Placing an Order', () {
    testWidgets('successfully places an order from the cart', (tester) async {
      // Given: The app is running and the user is logged in
      app.main();
      await tester.pumpAndSettle();

      // Navigate to login
      await tester.tap(find.byKey(const Key('login-button')));
      await tester.pumpAndSettle();
      await tester.enterText(find.byKey(const Key('email-field')), 'alice@example.com');
      await tester.enterText(find.byKey(const Key('password-field')), 'secret');
      await tester.tap(find.byKey(const Key('submit-login')));
      await tester.pumpAndSettle();

      // When: She navigates to a product and adds it to cart
      await tester.tap(find.byKey(const Key('product-rust-book')));
      await tester.pumpAndSettle();
      await tester.tap(find.byKey(const Key('add-to-cart')));
      await tester.pumpAndSettle();

      // And: She places the order from the cart screen
      await tester.tap(find.byKey(const Key('go-to-cart')));
      await tester.pumpAndSettle();
      await tester.tap(find.byKey(const Key('place-order-button')));
      await tester.pumpAndSettle();

      // Then: Order confirmation is shown
      expect(find.byKey(const Key('order-confirmation')), findsOneWidget);
      expect(find.text('Order Placed'), findsOneWidget);
    });
  });
}

For widget tests (equivalent to React component tests):

// test/widgets/cart_summary_test.dart

import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:ecommerce_app/features/cart/cart_summary_widget.dart';
import 'package:ecommerce_app/features/cart/cart_service.dart';

class MockCartService extends Mock implements CartService {}

void main() {
  group('CartSummary Widget', () {
    late MockCartService mockCartService;

    setUp(() {
      mockCartService = MockCartService();
    });

    testWidgets('displays line items and total correctly', (tester) async {
      // Arrange: Set up mock data
      when(mockCartService.getItems()).thenReturn([
        CartItem(
          id: '1',
          name: 'Rust Programming Book',
          quantity: 2,
          unitPriceCents: 4500,
          currency: 'USD',
        ),
      ]);

      // Act: Build the widget
      await tester.pumpWidget(
        MaterialApp(
          home: CartSummaryWidget(cartService: mockCartService),
        ),
      );

      // Assert: Check the rendered content
      expect(find.text('Rust Programming Book'), findsOneWidget);
      expect(find.text('Qty: 2'), findsOneWidget);
      expect(find.text('Total: USD 90.00'), findsOneWidget);
    });

    testWidgets('place order button is disabled when cart is empty', (tester) async {
      when(mockCartService.getItems()).thenReturn([]);

      await tester.pumpWidget(
        MaterialApp(home: CartSummaryWidget(cartService: mockCartService)),
      );

      final button = tester.widget<ElevatedButton>(
        find.byKey(const Key('place-order-button')),
      );
      expect(button.onPressed, isNull); // null onPressed = disabled
    });
  });
}

The Flutter Domain Layer

If you are building a Flutter app with offline capability or complex client-side business logic, you can apply DDD inside the Flutter app itself.

The domain layer in Flutter has no Flutter dependencies. No widgets. No Scaffold. Just pure Dart.

// domain/cart/cart.dart

/// Cart is the Aggregate Root for the shopping cart context.
/// In Flutter, this domain model is client-side.
/// It mirrors the server-side domain in spirit, but has its own rules.
class Cart {
  final CartId id;
  final String customerId;
  final List<CartItem> _items;

  Cart({required this.id, required this.customerId, List<CartItem>? items})
      : _items = items ?? [];

  List<CartItem> get items => List.unmodifiable(_items);

  /// The domain rule: quantity must be between 1 and 99.
  void addItem({required String productId, required String name,
      required int unitPriceCents, required String currency, int quantity = 1}) {
    if (quantity < 1 || quantity > 99) {
      throw CartException('Quantity must be between 1 and 99');
    }

    final existing = _findItem(productId);
    if (existing != null) {
      final newQty = existing.quantity + quantity;
      if (newQty > 99) throw CartException('Cannot add more than 99 of the same item');
      _replaceItem(existing.copyWith(quantity: newQty));
    } else {
      _items.add(CartItem(
        productId: productId,
        name: name,
        unitPriceCents: unitPriceCents,
        currency: currency,
        quantity: quantity,
      ));
    }
  }

  void removeItem(String productId) {
    _items.removeWhere((item) => item.productId == productId);
  }

  /// Total uses integer arithmetic (cents) to avoid floating-point errors.
  int get totalCents => _items.fold(0, (sum, item) => sum + item.totalCents);

  bool get isEmpty => _items.isEmpty;

  CartItem? _findItem(String productId) =>
      _items.where((i) => i.productId == productId).firstOrNull;

  void _replaceItem(CartItem updated) {
    final index = _items.indexWhere((i) => i.productId == updated.productId);
    if (index >= 0) _items[index] = updated;
  }
}
// test/domain/cart_test.dart (Pure Dart tests, no Flutter needed)

import 'package:test/test.dart';
import 'package:ecommerce_app/domain/cart/cart.dart';

void main() {
  group('Cart', () {
    late Cart cart;

    setUp(() {
      cart = Cart(id: CartId('cart-1'), customerId: 'customer-1');
    });

    test('starts empty', () {
      expect(cart.isEmpty, isTrue);
      expect(cart.totalCents, equals(0));
    });

    test('adds an item and calculates total', () {
      cart.addItem(
        productId: 'prod-1',
        name: 'Book',
        unitPriceCents: 4500,
        currency: 'USD',
        quantity: 2,
      );

      expect(cart.items.length, equals(1));
      expect(cart.totalCents, equals(9000)); // 2 * 4500
    });

    test('adds to existing item quantity instead of duplicating', () {
      cart.addItem(productId: 'prod-1', name: 'Book', unitPriceCents: 1000, currency: 'USD');
      cart.addItem(productId: 'prod-1', name: 'Book', unitPriceCents: 1000, currency: 'USD');

      expect(cart.items.length, equals(1));
      expect(cart.items.first.quantity, equals(2));
    });

    test('throws when quantity exceeds 99', () {
      expect(
        () => cart.addItem(productId: 'prod-1', name: 'Book',
            unitPriceCents: 1000, currency: 'USD', quantity: 100),
        throwsA(isA<CartException>()),
      );
    });

    test('removes an item', () {
      cart.addItem(productId: 'prod-1', name: 'Book', unitPriceCents: 1000, currency: 'USD');
      cart.removeItem('prod-1');

      expect(cart.isEmpty, isTrue);
    });
  });
}

These tests run in milliseconds. No Flutter engine. No widget tree. Pure domain logic in pure Dart.


Part 8 - Advanced Patterns: CQRS and Event Sourcing

CQRS: Separating Reads from Writes

Command Query Responsibility Segregation (CQRS) is a pattern that pairs beautifully with DDD.

The idea is simple: reading data and writing data have fundamentally different requirements. Writes need consistency, validation, and business rules. Reads need speed and flexibility.

In a simple architecture, the same model serves both. The Order aggregate that enforces invariants is also used to generate a customer-facing order list.

With CQRS, you split them.

Commands change state. They go through the domain, through use cases, through repositories. They enforce all business rules.

Queries read state. They go directly to the database, possibly a read-optimized replica or a materialized view. They skip the domain model entirely. They are just SQL.

// application/order/queries.go

// OrderSummary is a read model. It is NOT a domain object.
// It is a flat structure optimized for the "list orders" screen.
type OrderSummary struct {
    OrderID    string
    Status     string
    TotalUSD   float64
    ItemCount  int
    PlacedAt   time.Time
}

// OrderReader defines the Query port. Only reads, no domain logic.
type OrderReader interface {
    GetOrderSummaries(ctx context.Context, customerID string, page, pageSize int) ([]OrderSummary, int, error)
    GetOrderDetail(ctx context.Context, orderID string) (*OrderDetailView, error)
}
// infrastructure/persistence/postgres/order_reader.go

// PostgresOrderReader implements the query side with raw SQL.
// No ORM. No aggregate reconstitution. Just data retrieval.
type PostgresOrderReader struct {
    db *sql.DB // Can point to a read replica for performance.
}

func (r *PostgresOrderReader) GetOrderSummaries(
    ctx context.Context,
    customerID string,
    page, pageSize int,
) ([]OrderSummary, int, error) {
    const query = `
        SELECT
            o.id,
            o.status,
            o.total_amount / 100.0 AS total_usd,
            COUNT(li.id) AS item_count,
            o.placed_at
        FROM orders o
        LEFT JOIN order_line_items li ON li.order_id = o.id
        WHERE o.customer_id = $1
        GROUP BY o.id
        ORDER BY o.placed_at DESC
        LIMIT $2 OFFSET $3
    `

    offset := (page - 1) * pageSize
    rows, err := r.db.QueryContext(ctx, query, customerID, pageSize, offset)
    if err != nil {
        return nil, 0, err
    }
    defer rows.Close()

    var summaries []OrderSummary
    for rows.Next() {
        var s OrderSummary
        if err := rows.Scan(&s.OrderID, &s.Status, &s.TotalUSD, &s.ItemCount, &s.PlacedAt); err != nil {
            return nil, 0, err
        }
        summaries = append(summaries, s)
    }

    // Count total (separate query or use window functions in production)
    var total int
    _ = r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM orders WHERE customer_id = $1", customerID).Scan(&total)

    return summaries, total, rows.Err()
}

The HTTP handler uses both. Write commands go through the use case. Reads go through the reader:

// infrastructure/http/handlers/order_handler.go

type OrderHandler struct {
    placeOrder  *application.PlaceOrderUseCase  // Write side
    confirmOrder *application.ConfirmOrderUseCase
    orderReader  application.OrderReader         // Read side
}

// ListOrders is a pure query. No business logic. Just data retrieval.
func (h *OrderHandler) ListOrders(w http.ResponseWriter, r *http.Request) {
    customerID := r.Context().Value("customer_id").(string)
    page, _ := strconv.Atoi(r.URL.Query().Get("page"))
    if page < 1 { page = 1 }

    // Goes directly to the read model. Fast. Simple.
    summaries, total, err := h.orderReader.GetOrderSummaries(r.Context(), customerID, page, 20)
    if err != nil {
        http.Error(w, "failed to load orders", http.StatusInternalServerError)
        return
    }

    json.NewEncoder(w).Encode(map[string]interface{}{
        "orders": summaries,
        "total":  total,
        "page":   page,
    })
}

// PlaceOrder routes through the domain. Business rules enforced.
func (h *OrderHandler) PlaceOrder(w http.ResponseWriter, r *http.Request) {
    var req PlaceOrderRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid request", http.StatusBadRequest)
        return
    }

    customerID := r.Context().Value("customer_id").(string)

    orderID, err := h.placeOrder.Execute(r.Context(), application.PlaceOrderCommand{
        CustomerID: order.CustomerID(customerID),
        Items:      mapRequestItems(req.Items),
    })

    if err != nil {
        // Map domain errors to HTTP status codes
        if errors.Is(err, order.ErrEmptyOrder) {
            http.Error(w, err.Error(), http.StatusBadRequest)
            return
        }
        http.Error(w, "failed to place order", http.StatusInternalServerError)
        return
    }

    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(map[string]string{"order_id": string(orderID)})
}

Event Sourcing: When History Matters

Event Sourcing takes domain events one step further.

Instead of storing the current state of an aggregate, you store the sequence of events that led to the current state. The current state is derived by replaying all events.

This is natural for certain domains: banking (every transaction is an event), audit systems (every change must be recorded), collaboration tools (every edit is an event).

// domain/account/account_event_sourced.go

// BankAccount uses event sourcing. Its state is derived from events.
type BankAccount struct {
    id           AccountID
    ownerID      CustomerID
    balance      Money
    status       AccountStatus
    version      int
    uncommitted  []DomainEvent
}

// Apply applies a domain event to the aggregate.
// This is the only way state changes. No direct field manipulation.
func (a *BankAccount) Apply(event DomainEvent) {
    switch e := event.(type) {
    case AccountOpened:
        a.id      = e.AccountID
        a.ownerID = e.OwnerID
        a.balance = Money{amount: 0, currency: e.Currency}
        a.status  = AccountStatusActive

    case MoneyDeposited:
        a.balance, _ = a.balance.Add(e.Amount)

    case MoneyWithdrawn:
        a.balance, _ = a.balance.Subtract(e.Amount)

    case AccountClosed:
        a.status = AccountStatusClosed
    }
    a.version++
}

// Deposit records a MoneyDeposited event.
// It does not change state directly. It creates an event, which changes state via Apply.
func (a *BankAccount) Deposit(amount Money) error {
    if a.status != AccountStatusActive {
        return ErrAccountNotActive
    }
    if amount.Amount() <= 0 {
        return ErrInvalidAmount
    }

    event := MoneyDeposited{
        AccountID:   a.id,
        Amount:      amount,
        DepositedAt: time.Now(),
    }
    a.Apply(event)
    a.uncommitted = append(a.uncommitted, event)
    return nil
}

// Reconstitute rebuilds an account from its event history.
func Reconstitute(events []DomainEvent) *BankAccount {
    a := &BankAccount{}
    for _, event := range events {
        a.Apply(event)
    }
    return a
}

The repository for an event-sourced aggregate stores events, not state:

// infrastructure/persistence/postgres/event_store.go

type PostgresEventStore struct {
    db *sql.DB
}

// Load retrieves all events for an aggregate and reconstitutes it.
func (es *PostgresEventStore) Load(ctx context.Context, accountID AccountID) (*account.BankAccount, error) {
    const query = `
        SELECT event_type, event_data, occurred_at
        FROM account_events
        WHERE account_id = $1
        ORDER BY version ASC
    `

    rows, err := es.db.QueryContext(ctx, query, string(accountID))
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var events []account.DomainEvent
    for rows.Next() {
        var eventType string
        var eventData []byte
        var occurredAt time.Time

        if err := rows.Scan(&eventType, &eventData, &occurredAt); err != nil {
            return nil, err
        }

        event, err := deserializeEvent(eventType, eventData)
        if err != nil {
            return nil, err
        }
        events = append(events, event)
    }

    if len(events) == 0 {
        return nil, account.ErrAccountNotFound
    }

    return account.Reconstitute(events), nil
}

// Append adds new events to the event store.
func (es *PostgresEventStore) Append(ctx context.Context, acc *account.BankAccount) error {
    events := acc.PullUncommittedEvents()
    if len(events) == 0 {
        return nil
    }

    tx, err := es.db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    for i, event := range events {
        data, err := json.Marshal(event)
        if err != nil {
            return err
        }

        _, err = tx.ExecContext(ctx, `
            INSERT INTO account_events (account_id, event_type, event_data, version, occurred_at)
            VALUES ($1, $2, $3, $4, $5)
        `, string(acc.ID()), event.EventName(), data, acc.Version()-len(events)+i+1, event.OccurredAt())
        if err != nil {
            return err
        }
    }

    return tx.Commit()
}

Part 9 - The Complete Agile Tool Stack

Tools Every DDD + BDD Team Should Use

After working with multiple teams applying this approach, here is the tool stack that consistently works.

Domain Discovery

Event Storming (the workshop) — no software needed. Sticky notes, a long wall, and the right people in the room.

Miro or MURAL — digital Event Storming when the team is remote. Use the Event Storming template.

Development: Go

godog — BDD framework, Cucumber for Go. testify — Assertions and mock utilities. mockery — Auto-generate mocks from interfaces. golangci-lint — Linting with many useful rules including architecture enforcement. migrate — Database migration tool. Used with the golang-migrate/migrate library. sqlc — Generate type-safe Go code from SQL queries. Excellent for the CQRS read side.

# sqlc.yaml
version: "2"
sql:
  - engine: "postgresql"
    queries: "infrastructure/persistence/postgres/queries"
    schema: "infrastructure/persistence/postgres/migrations"
    gen:
      go:
        package: "postgresreader"
        out: "infrastructure/persistence/postgres/generated"

With sqlc, you write SQL queries and get type-safe Go functions generated automatically. No ORM magic, no string queries at runtime.

Development: React

Playwright — E2E tests with full browser automation. React Testing Library — Component tests without testing implementation details. Vitest — Fast unit test runner for JavaScript/TypeScript. MSW (Mock Service Worker) — Mock HTTP calls at the network level. The frontend adapter testing tool.

// src/mocks/handlers.ts (MSW handlers — mock the API for tests)
import { http, HttpResponse } from "msw";

export const handlers = [
  http.post("/api/orders", async ({ request }) => {
    const body = (await request.json()) as PlaceOrderRequest;

    if (body.items.length === 0) {
      return HttpResponse.json(
        { error: "cannot place order without items" },
        { status: 400 },
      );
    }

    return HttpResponse.json(
      {
        order_id: "order-test-123",
        status: "draft",
        total: { amount: 9000, currency: "USD" },
      },
      { status: 201 },
    );
  }),

  http.get("/api/orders", ({ request }) => {
    const url = new URL(request.url);
    const page = url.searchParams.get("page") || "1";

    return HttpResponse.json({
      orders: [
        {
          order_id: "order-1",
          status: "confirmed",
          total_usd: 90.0,
          item_count: 2,
        },
      ],
      total: 1,
      page: parseInt(page),
    });
  }),
];

Development: Flutter

flutter_test — Built-in widget testing. integration_test — Official Flutter E2E testing package. mockito — Mock generation for Dart. bloc_test — If using the BLoC pattern, this is the test utility. freezed — Code generation for immutable domain objects and value objects in Dart.

CI/CD

GitHub Actions — Pipeline automation. golangci-lint-action — Go linting in CI. Docker Compose — Local and CI environments. Testcontainers — Real databases in integration tests.

Documentation and Communication

ADR (Architecture Decision Records) — Document why architectural decisions were made.

# ADR-0001: Use Event-Driven Communication Between Bounded Contexts

## Status

Accepted

## Context

We have three bounded contexts: Orders, Fulfillment, and Notifications.
They need to communicate when order states change.

## Decision

We will use domain events published to a Kafka topic for cross-context communication.
Each context subscribes only to the events it needs.
No direct service-to-service calls between contexts.

## Consequences

Positive: Contexts are decoupled. A context can be deployed independently.
Positive: New contexts can subscribe to events without modifying existing ones.
Negative: Eventual consistency. The Notifications context may be slightly behind.
Negative: Kafka adds operational complexity.

Living documentation — The Gherkin feature files are the living documentation. They are always up to date because they run with every build.


Part 10 - Putting It All Together: A Week in the Life

Monday: Sprint Planning

The team gathers for sprint planning. The product owner presents three user stories for this sprint, all in the Order bounded context.

Story 1: “As a customer, I can apply a discount code to my order.” Story 2: “As an operator, I can see all orders placed in the last 24 hours.” Story 3: “As the system, when an order is confirmed, the inventory should be reserved.”

The developer says: “Story 1 changes the Order aggregate. We need a new DiscountCode value object. Story 2 is a pure query, CQRS read side. Story 3 is a cross-context event: OrderConfirmed triggers inventory reservation in the Inventory context.”

The QA engineer says: “For story 1, what happens when the discount code is expired? What if it has already been used the maximum number of times?”

The product owner says: “Expired codes should fail with a clear message. Each code has a max-uses field. If the code is at its limit, reject it.”

New scenarios are added to features/order/apply_discount.feature on the spot.

The three amigos have done their job. The feature is specified before a line of code is written.

Tuesday and Wednesday: TDD Cycle

The developer starts with the domain.

# Step 1: Write a failing unit test for the DiscountCode value object
go test ./domain/order/... # FAIL - DiscountCode undefined

# Step 2: Create the DiscountCode value object
# Write minimum code to pass the test
go test ./domain/order/... # PASS

# Step 3: Refactor

# Step 4: Write use case unit test
go test ./application/order/... # FAIL - ApplyDiscount use case undefined

# Step 5: Implement the use case
go test ./application/order/... # PASS

# Repeat for all scenarios

By Wednesday afternoon, all unit tests are green. The domain is complete.

Thursday: BDD Tests and Integration

The developer runs the BDD tests:

go test -v -run TestFeatures ./... # Some scenarios FAIL (step definitions not written)

The step definitions are added. The BDD tests connect the Gherkin scenarios to the use cases. By Thursday evening:

go test -v -run TestFeatures ./...
# Feature: Applying a Discount Code
#   Scenario: Valid discount code applied         PASSED
#   Scenario: Expired discount code rejected      PASSED
#   Scenario: Code at maximum uses rejected       PASSED
#   Scenario: Non-existent code rejected          PASSED
# 4 scenarios (4 passed)

Friday: PR, CI, and Deploy

The developer opens a pull request. GitHub Actions runs:

  1. golangci-lint — Architecture rules check: no business logic in infrastructure. Pass.
  2. go test -short -race ./... — All unit tests. Pass.
  3. Start PostgreSQL via service container. Run integration tests. Pass.
  4. Run BDD tests. Pass.
  5. Build Docker image. Push to registry.
  6. Deploy to staging.

The product owner visits staging and tests the feature manually (one last sanity check). Happy. The PR is merged.

Total time from “idea in sprint planning” to “feature in staging”: four working days.


Conclusion: The System Remembers What You Forget

Here is the thing about DDD, hexagonal architecture, BDD, and TDD.

They are not just technical practices. They are a form of organizational memory.

Code without a domain model forgets what the business actually does. It accumulates layers of workarounds, workarounds for workarounds, and comments that say “don’t touch this, it will break.” The original intent is lost. The people who understood it have left.

Code with a domain model remembers. The entities have the business names. The use cases describe the business workflows. The feature files describe the behavior in plain language. The tests prove the behavior is correct.

A new developer can join the team, read the feature files, read the domain code, and understand what the system does without asking anyone.

That is the promise of this approach.

It is not free. It requires discipline. It requires the Three Amigos to meet before coding. It requires developers to think about language. It requires tests to be written before code. It requires architectural boundaries to be respected.

But the alternative — the 900-line function, the 87-column table, the “works on my machine” deployment — that is not free either. It is just expensive in a way that is invisible until it collapses.

The systems in this guide do not collapse. They grow. They adapt. They remember.

That is worth every hour of investment.


Quick Reference: The Domain Vocabulary

DDD ConceptDefinitionGo Example
EntityObject with identity. Changes over time.Order, Customer
Value ObjectObject defined by its value. Immutable. No identity.Money, Address
AggregateCluster of entities/VOs. Has a root. Treated as a unit.Order (root + items)
Domain EventSomething that happened. Past tense. Immutable.OrderConfirmed
RepositoryPort for loading/saving aggregates.OrderRepository
Use CaseApplication service. Orchestrates the domain.ConfirmOrderUseCase
Bounded ContextBoundary within which a model is consistent.Orders, Fulfillment
Ubiquitous LanguageShared vocabulary between developers and domain experts.”Order”, “Line Item”
Anti-Corruption LayerTranslation layer protecting one context from another’s model.IdentityServiceACL
PortInterface defined by the application layer.OrderRepository (interface)
AdapterImplementation of a port in the infrastructure layer.PostgresOrderRepository

Quick Reference: The Testing Strategy

Feature File (Gherkin)
   Scenario: ...
       |
       v
   Step Definitions (Go / TypeScript / Dart)
       |
       v
   Use Cases (Application Layer)
       |
       v
   Domain (Entities, Value Objects, Aggregates)
       |
       v
   Mock Adapters (for unit/BDD tests)
   Real Adapters (for integration tests)

Test Types:
- Domain unit tests:      test entities and VOs in isolation
- Use case unit tests:    test use cases with mock adapters
- BDD tests:              test full scenarios via step definitions → use cases
- Integration tests:      test adapters (repository) against real database
- E2E tests:              test the full stack including frontend

Quick Reference: Scrum + DDD Ceremony Updates

CeremonyWithout DDDWith DDD
RefinementDeveloper guesses requirementsThree Amigos write Gherkin scenarios together
PlanningStories estimated without contextStories mapped to bounded contexts, dependencies identified
Daily Standup”Working on USER-123""Working on discount code in the Order context”
ReviewDemo the UIDemo + review Gherkin scenarios with the product owner
Retrospective”Tests took too long""Which bounded context caused the most change requests?”
DoDTests pass, QA approvedDomain model consistent, events published, BDD scenarios pass