Building a Go Microservice with Hexagonal Architecture, CQRS, and uber/fx

May 16, 2026 posted by Ilman Iqbal

When writing backend services in Go, it is tempting to start with a flat structure where HTTP handlers directly execute database queries and format JSON responses. While this works for small scripts, it quickly becomes unmaintainable as services grow in complexity.

In this guide, we walk through building a production-shaped product-management microservice in Go using Hexagonal Architecture (Ports and Adapters), a CQRS (Command Query Responsibility Segregation) split, interfaces, gin, zerolog, google/uuid, mongodb, and uber/fx for dependency injection. We also explore locking mechanisms, database transactions, and integration testing with Testcontainers.

By the end you will understand how to structure a Go service so its core business logic stays completely independent of the web framework, the database, the logger, or any other infrastructure choice.

Prerequisites: If you are new to Go or want to brush up on goroutines, channels, mutexes, context, or multi-module workspaces, read the companion guide: Go Fundamentals — The Complete Guide to Core Concepts, Concurrency, and Tooling.


1. Architectural Concepts: Hexagonal Architecture, CQRS & Ports

What is a REST API?

A REST API (Representational State Transfer Application Programming Interface) is a way for applications to communicate over HTTP by following REST principles:

What is Hexagonal Architecture? (A 60-Second Crash Course)

Hexagonal architecture — also called ports and adapters — is a way of organising code so the core business logic doesn't depend on the framework, the database, or any other external concern. Instead, the core defines ports (interfaces) that describe what it needs from the outside world, and adapters are the concrete things that plug into those ports.

Three layers, in order from inside to outside:

  1. Domain Layer — pure business entities and rules. It has zero external dependencies (no HTTP, no database drivers, no JSON tags).
  2. Application Layer — the use cases that orchestrate the domain (e.g. ProductCommandService, ProductQueryService). It depends only on the domain and on a set of port interfaces.
  3. Adapters Layer — concrete implementations of those ports. Two flavours:
    • Inbound (Driving) — things that call into the application (e.g. HTTP handlers, gRPC servers, CLI commands, message consumers).
    • Outbound (Driven) — things the application calls out to (e.g. MongoDB, PostgreSQL, in-memory repository, Redis cache, third-party APIs).

The Inward Dependency Rule: Dependencies always point strictly inward: adapters depend on application, application depends on domain, and the domain depends on nothing. Swap MongoDB for Postgres or an in-memory map? Write a new outbound adapter — nothing in the application or domain layer changes.

Where Should Interfaces Live?

The general rule is: Interfaces belong to the layer that depends on them, not the layer implementing them.

// application/ports/repository.go
package ports

import "cmd/product-management/domain" // or "yourapp/domain"

Understanding Ports: Inbound vs Outbound Ports

"ports" is a standard concept in Hexagonal Architecture, the package name itself is just a convention. A port is simply an interface that defines how the application core communicates with the outside world.

1. Inbound Ports

Used by things that call your application.

type ProductService interface {
    AddProduct(cmd AddProductCommand) error
    ListProducts() ([]Product, error)
}

Called by:

Many Java/C# Hexagonal architectures use inbound ports heavily. In Go, concrete application service structs are often called directly by inbound adapters, though interfaces can be introduced whenever multiple driving adapters or mocks require them.

2. Outbound Ports

Used by your application to call external systems.

type ProductRepository interface {
    Save(product Product) error
    FindAll() ([]Product, error)
}

Implemented by:

In Go, this is the most common use of ports.

What is CQRS?

Command Query Responsibility Segregation (CQRS) splits your code into a "write side" that executes commands (e.g. AddProduct) and a "read side" that runs queries (e.g. ListProducts).

The two sides can use different code paths and even different data stores. In our service, we structure use cases into ProductCommandService and ProductQueryService, allowing each side to evolve independently.

Project Layout Conventions

How you organize your application directory depends on the scale of the service:

For a medium or large project:

application/
├── product/
│   ├── command_service.go
│   └── query_service.go
├── customer/
│   ├── command_service.go
│   └── query_service.go

For a small project:

application/
├── product_command_service.go
├── product_query_service.go
├── customer_command_service.go
├── customer_query_service.go

Interface Implementation Naming: Go vs Java

If ports.go (or ports/repository.go) contains the ProductRepository interface, and you're implementing it in an outbound adapter, then avoid names like ProductRepositoryImpl because that's more of a Java convention than a Go convention.

Better options in Go:

High-Level Architecture Structure

If you have the following structure:

adapters/
├── in/
│   └── http/
│       ├── handler.go
│       └── product_handler.go
│
└── out/
    └── mongo/
        └── product_repository.go

application/
├── product_command_service.go
├── product_query_service.go
└── ports/
    └── repository.go

domain/
├── errors.go
└── product.go

Here is how the adapters and wiring fit together:

// adapters/in/http
package http

type Handler struct {
    commands *application.ProductCommandService
    queries  *application.ProductQueryService
    logger   zerolog.Logger
}
// adapters/out/mongo
package mongo

type ProductRepository struct {
    coll *mongo.Collection // or db *sql.DB in SQL adapters
}
// Wiring in main.go:
func main() {
    // create database connection
    db := createMongoConnection()

    // create repository adapter
    productRepo := mongoadapter.NewProductRepository(db)

    // create application services
    productCommandService := app.NewProductCommandService(productRepo)
    productQueryService := app.NewProductQueryService(productRepo)

    // create http handler
    productHandler := httpadapter.NewProductHandler(
        productCommandService,
        productQueryService,
    )

    // register routes
    mux := http.NewServeMux()

    mux.HandleFunc("GET /products", productHandler.ListProducts)
    mux.HandleFunc("POST /products", productHandler.AddProduct)

    log.Println("server running on :8080")
    log.Fatal(http.ListenAndServe(":8080", mux))
}

2. Building the product-management Microservice

The Starting Point

We begin with the simplest possible main.go — just enough to serve a single endpoint:

package main

import (
    "fmt"
    "github.com/gin-gonic/gin"
)

func main() {
    fmt.Println("Hello, World!")

    server := gin.Default()
    server.GET("/products", getProducts)
    server.Run(":8080")
}

func getProducts(c *gin.Context) {
    c.JSON(200, gin.H{"name": "Car Wipers"})
}

Let's unpack what each gin call does:

That works, but everything is mixed together: routing, business logic, and data. We cannot test business rules in isolation or swap databases without modifying the HTTP handler.

The Target Project Layout

product-management/
├── main.go                                ← Composition root: wires interfaces to adapters
├── go.mod
├── go.sum
│
├── domain/                                ← Pure business rules, no I/O
│   ├── product.go                         ←   Product entity + value types + validation
│   └── errors.go                          ←   Domain sentinel errors
│
├── application/                           ← Use cases (drives the domain)
│   ├── ports/
│   │   └── repository.go                  ←   ProductRepository, IDGenerator ports
│   ├── product_command_service.go         ←   Write side of CQRS (AddProduct)
│   └── product_query_service.go           ←   Read side of CQRS (ListProducts)
│
├── adapters/                              ← All I/O lives here
│   ├── in/
│   │   └── http/                          ← Inbound HTTP adapter
│   │       ├── handler.go                 ←   Router and Handler struct
│   │       └── product_handler.go         ←   Endpoint request/response logic
│   └── out/                               ← Outbound adapters (databases)
│       ├── inmemory/
│       │   └── product_repository.go      ←   In-memory map repository
│       └── mongo/
│           └── product_repository.go      ←   MongoDB repository
│
└── tests/
    └── product_api_test.go                ← Integration tests with Testcontainers

Step 1 — The Domain Layer

The Product entity exposes a constructor that enforces invariants. Fields are unexported so an invalid Product cannot be created with a struct literal.

// domain/product.go
package product

import "strings"

// ID is the unique identifier of a Product.
type ID string

// Name is the product display name.
type Name string

// Price is the product price in minor currency units (e.g. cents) so we
// can keep the value as int64 and avoid floating-point precision issues.
type Price int64

// Product is the domain entity.
type Product struct {
    id    ID
    name  Name
    price Price
}

// New builds a valid Product or returns an error explaining why the input is invalid.
func New(id ID, name Name, price Price) (Product, error) {
    if strings.TrimSpace(string(name)) == "" {
        return Product{}, ErrEmptyName
    }
    if price <= 0 {
        return Product{}, ErrInvalidPrice
    }
    return Product{
        id:    id,
        name:  name,
        price: price,
    }, nil
}

func (p Product) ID() ID       { return p.id }
func (p Product) Name() Name   { return p.name }
func (p Product) Price() Price { return p.price }
// domain/errors.go
package product

import "errors"

var (
    ErrEmptyName     = errors.New("product name is empty")
    ErrInvalidPrice  = errors.New("product price must be positive")
    ErrAlreadyExists = errors.New("product already exists")
)

Step 2 — The Application Layer (Ports + Use Cases)

Ports are interfaces that describe what the application needs from external systems.

// application/ports/repository.go
package ports

import (
    "context"

    product "cmd/product-management/domain"
)

// ProductRepository is the outbound port for persisting and loading products.
type ProductRepository interface {
    Save(ctx context.Context, p product.Product) error
    FindAll(ctx context.Context) ([]product.Product, error)
}

// IDGenerator abstracts ID creation so the domain stays deterministic and easy to test.
type IDGenerator interface {
    NewID() product.ID
}
// application/product_command_service.go
package application

import (
    "context"
    "fmt"

    "cmd/product-management/application/ports"
    product "cmd/product-management/domain"
)

// ProductCommandService is the write side of CQRS.
type ProductCommandService struct {
    repo ports.ProductRepository
    ids  ports.IDGenerator
}

func NewProductCommandService(repo ports.ProductRepository, ids ports.IDGenerator) *ProductCommandService {
    return &ProductCommandService{repo: repo, ids: ids}
}

type AddProductCommand struct {
    Name  string
    Price int64
}

type AddProductResult struct {
    ID string
}

func (s *ProductCommandService) AddProduct(ctx context.Context, cmd AddProductCommand) (AddProductResult, error) {
    id := s.ids.NewID()

    p, err := product.New(id, product.Name(cmd.Name), product.Price(cmd.Price))
    if err != nil {
        return AddProductResult{}, fmt.Errorf("add product: %w", err)
    }

    if err := s.repo.Save(ctx, p); err != nil {
        return AddProductResult{}, fmt.Errorf("save product: %w", err)
    }
    return AddProductResult{ID: string(id)}, nil
}
// application/product_query_service.go
package application

import (
    "context"
    "fmt"

    "cmd/product-management/application/ports"
    product "cmd/product-management/domain"
)

// ProductQueryService is the read side of CQRS.
type ProductQueryService struct {
    repo ports.ProductRepository
}

func NewProductQueryService(repo ports.ProductRepository) *ProductQueryService {
    return &ProductQueryService{repo: repo}
}

// ProductView is the DTO returned by the read side, independent of the domain entity.
type ProductView struct {
    ID    string `json:"id"`
    Name  string `json:"name"`
    Price int64  `json:"price"`
}

func (s *ProductQueryService) ListProducts(ctx context.Context) ([]ProductView, error) {
    products, err := s.repo.FindAll(ctx)
    if err != nil {
        return nil, fmt.Errorf("list products: %w", err)
    }

    views := make([]ProductView, 0, len(products))
    for _, p := range products {
        views = append(views, viewOf(p))
    }
    return views, nil
}

func viewOf(p product.Product) ProductView {
    return ProductView{
        ID:    string(p.ID()),
        Name:  string(p.Name()),
        Price: int64(p.Price()),
    }
}

Step 3 — Outbound Adapters (In-Memory & MongoDB)

In-Memory Database Adapter

// adapters/out/inmemory/product_repository.go
package inmemory

import (
    "context"
    "sort"
    "sync"

    product "cmd/product-management/domain"
)

type ProductRepository struct {
    mu    sync.RWMutex
    store map[product.ID]product.Product
}

func NewProductRepository() *ProductRepository {
    return &ProductRepository{
        store: make(map[product.ID]product.Product),
    }
}

func (r *ProductRepository) Save(_ context.Context, p product.Product) error {
    r.mu.Lock()
    defer r.mu.Unlock()

    if _, exists := r.store[p.ID()]; exists {
        return product.ErrAlreadyExists
    }
    r.store[p.ID()] = p
    return nil
}

func (r *ProductRepository) FindAll(_ context.Context) ([]product.Product, error) {
    r.mu.RLock()
    out := make([]product.Product, 0, len(r.store))
    for _, p := range r.store {
        out = append(out, p)
    }
    r.mu.RUnlock()

    sort.Slice(out, func(i, j int) bool {
        return out[i].Name() < out[j].Name()
    })
    return out, nil
}

MongoDB Outbound Adapter

// adapters/out/mongo/product_repository.go
package mongo

import (
    "context"
    "errors"
    "fmt"

    product "cmd/product-management/domain"

    "go.mongodb.org/mongo-driver/bson"
    mongodriver "go.mongodb.org/mongo-driver/mongo"
    "go.mongodb.org/mongo-driver/mongo/options"
)

const (
    databaseName   = "product_management"
    collectionName = "products"
)

type productDocument struct {
    ID    string `bson:"_id"`
    Name  string `bson:"name"`
    Price int64  `bson:"price"`
}

type ProductRepository struct {
    coll *mongodriver.Collection
}

func NewProductRepository(client *mongodriver.Client) *ProductRepository {
    return &ProductRepository{
        coll: client.Database(databaseName).Collection(collectionName),
    }
}

func (r *ProductRepository) Save(ctx context.Context, p product.Product) error {
    doc := productDocument{
        ID:    string(p.ID()),
        Name:  string(p.Name()),
        Price: int64(p.Price()),
    }

    _, err := r.coll.InsertOne(ctx, doc)
    if err != nil {
        if mongodriver.IsDuplicateKeyError(err) {
            return product.ErrAlreadyExists
        }
        return fmt.Errorf("insert product: %w", err)
    }
    return nil
}

func (r *ProductRepository) FindAll(ctx context.Context) ([]product.Product, error) {
    opts := options.Find().SetSort(bson.D{{Key: "name", Value: 1}})
    cursor, err := r.coll.Find(ctx, bson.D{}, opts)
    if err != nil {
        return nil, fmt.Errorf("find products: %w", err)
    }
    defer cursor.Close(ctx)

    var docs []productDocument
    if err := cursor.All(ctx, &docs); err != nil {
        return nil, fmt.Errorf("decode products: %w", err)
    }

    out := make([]product.Product, 0, len(docs))
    for _, doc := range docs {
        p, err := product.New(product.ID(doc.ID), product.Name(doc.Name), product.Price(doc.Price))
        if err != nil {
            return nil, fmt.Errorf("rebuild product %q: %w", doc.ID, err)
        }
        out = append(out, p)
    }
    return out, nil
}

func Connect(ctx context.Context, uri string) (*mongodriver.Client, error) {
    client, err := mongodriver.Connect(ctx, options.Client().ApplyURI(uri))
    if err != nil {
        return nil, fmt.Errorf("connect mongo: %w", err)
    }
    if err := client.Ping(ctx, nil); err != nil {
        _ = client.Disconnect(ctx)
        return nil, fmt.Errorf("ping mongo: %w", err)
    }
    return client, nil
}

func Disconnect(ctx context.Context, client *mongodriver.Client) error {
    if client == nil {
        return nil
    }
    err := client.Disconnect(ctx)
    if err != nil && !errors.Is(err, mongodriver.ErrClientDisconnected) {
        return err
    }
    return nil
}

Step 4 — The Inbound HTTP Adapter

The HTTP adapter maps incoming HTTP requests to use-case commands/queries and formats JSON responses.

// adapters/in/http/handler.go
package http

import (
    "net/http"

    "cmd/product-management/application"

    "github.com/gin-gonic/gin"
    "github.com/rs/zerolog"
)

type Handler struct {
    commands *application.ProductCommandService
    queries  *application.ProductQueryService
    logger   zerolog.Logger
}

func NewHandler(
    commands *application.ProductCommandService,
    queries *application.ProductQueryService,
    logger zerolog.Logger,
) *Handler {
    return &Handler{
        commands: commands,
        queries:  queries,
        logger:   logger,
    }
}

func (h *Handler) Routes() http.Handler {
    engine := gin.New()
    engine.Use(gin.Recovery())
    engine.HandleMethodNotAllowed = true

    engine.GET("/products", h.listProducts)
    engine.POST("/products", h.addProduct)

    return engine
}
// adapters/in/http/product_handler.go
package http

import (
    "errors"
    "net/http"

    "cmd/product-management/application"
    product "cmd/product-management/domain"

    "github.com/gin-gonic/gin"
)

func (h *Handler) listProducts(c *gin.Context) {
    views, err := h.queries.ListProducts(c.Request.Context())
    if err != nil {
        h.logger.Error().Err(err).Msg("list products failed")
        c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list products"})
        return
    }
    c.JSON(http.StatusOK, gin.H{"products": views})
}

type addProductRequest struct {
    Name  string `json:"name"`
    Price int64  `json:"price"`
}

func (h *Handler) addProduct(c *gin.Context) {
    var req addProductRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"})
        return
    }

    res, err := h.commands.AddProduct(c.Request.Context(), application.AddProductCommand{
        Name:  req.Name,
        Price: req.Price,
    })
    if err != nil {
        switch {
        case errors.Is(err, product.ErrEmptyName),
            errors.Is(err, product.ErrInvalidPrice),
            errors.Is(err, product.ErrAlreadyExists):
            c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        default:
            h.logger.Error().Err(err).Msg("add product failed")
            c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add product"})
        }
        return
    }

    c.JSON(http.StatusCreated, gin.H{"id": res.ID})
}

Step 5 — The Composition Root (Manual Wiring)

main.go is the composition root: the only place that knows about both abstract ports and concrete adapters. Here is how manual wiring looks:

// main.go (Manual wiring example)
func main() {
    // 1. Create database connection
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    client, err := mongo.Connect(ctx, "mongodb://localhost:27017")
    if err != nil {
        log.Fatal(err)
    }

    // 2. Create repository adapter (satisfies ports.ProductRepository)
    productRepo := mongoadapter.NewProductRepository(client)
    idGen := uuidIDs{}

    // 3. Create application use-case services
    productCommandService := application.NewProductCommandService(productRepo, idGen)
    productQueryService := application.NewProductQueryService(productRepo)

    // 4. Create HTTP handler adapter
    logger := zerolog.New(os.Stdout).With().Timestamp().Logger()
    handler := httpadapter.NewHandler(productCommandService, productQueryService, logger)

    // 5. Start HTTP server
    log.Println("server running on :8080")
    log.Fatal(http.ListenAndServe(":8080", handler.Routes()))
}

Step 6 — Build and Test

go build ./...
go run .

# in another terminal:
# Add a product
curl -X POST -H 'Content-Type: application/json' \
     -d '{"name":"Car Wipers","price":1999}' \
     http://localhost:8080/products
# => 201 {"id":"7d8d17cf-909c-4173-bd90-4e777acc60aa"}

# List products
curl http://localhost:8080/products
# => 200 {"products":[{"id":"...","name":"Car Wipers","price":1999}]}

3. Deep Dive: The "Empty go.mod" & Toolchain Mechanics

An Interesting Observation: The Empty go.mod

Before adding external packages, the entire go.mod was just three lines:

module cmd/product-management

go 1.22

Why? Because Go's standard library provides full solutions for routing, logging, ID generation, and synchronization out of the box:

Capability Stdlib equivalent Ecosystem library
HTTP Routing net/http ServeMux (Go 1.22+) github.com/gin-gonic/gin
Structured Logging log/slog (Go 1.21+) github.com/rs/zerolog
ID Generation crypto/rand + encoding/hex github.com/google/uuid
Concurrency sync.RWMutex In-memory storage

A Note on Go Toolchains

Since Go 1.21, Go supports automatic toolchain switching. If go.mod specifies a version newer than your installed Go compiler, Go will automatically download the required toolchain.

You can control this behavior using GOTOOLCHAIN:

GOTOOLCHAIN=local go build ./...
Situation What to do
Building in a sandboxed / offline / CI environment GOTOOLCHAIN=local
You want strict reproducibility over which Go compiles code GOTOOLCHAIN=local
Adding a dep that requires a newer Go than yours Pin to an older version of the dep, or upgrade local Go

4. Declarative Dependency Injection with uber/fx

What is uber/fx?

uber/fx is a dependency injection framework for Go. You hand it a bag of constructors via fx.Provide(...); fx introspects parameter and return types, constructs the dependency graph in topological order, manages lifecycles (OnStart, OnStop hooks), and handles graceful shutdown.

Key fx primitives

The appOptions / newApp / main Pattern

This structure enables clean dynamic adapter selection (switching between MongoDB and In-Memory via environment variables) and allows integration tests to inject test overrides:

// main.go (Complete with uber/fx & dynamic adapter selection)
package main

import (
    "context"
    "errors"
    "net/http"
    "os"
    "strings"
    "time"

    httpadapter "cmd/product-management/adapters/in/http"
    "cmd/product-management/adapters/out/inmemory"
    mongoadapter "cmd/product-management/adapters/out/mongo"
    "cmd/product-management/application"
    "cmd/product-management/application/ports"
    product "cmd/product-management/domain"

    "github.com/gin-gonic/gin"
    "github.com/google/uuid"
    "github.com/rs/zerolog"
    "go.mongodb.org/mongo-driver/mongo"
    "go.uber.org/fx"
    "go.uber.org/fx/fxevent"
)

const (
    httpAddr            = ":8080"
    shutdownTimeout     = 5 * time.Second
    mongoConnectTimeout = 10 * time.Second
    defaultMongoURI     = "mongodb://localhost:27017"
)

type uuidIDs struct{}

func (uuidIDs) NewID() product.ID { return product.ID(uuid.NewString()) }

func newLogger() zerolog.Logger {
    return zerolog.New(os.Stdout).Level(zerolog.InfoLevel).With().Timestamp().Logger()
}

func asIDGenerator() ports.IDGenerator { return uuidIDs{} }

func productRepositoryKind() string {
    switch strings.ToLower(os.Getenv("PRODUCT_REPOSITORY")) {
    case "memory":
        return "memory"
    default:
        return "mongo"
    }
}

func mongoURI() string {
    if uri := os.Getenv("MONGO_URI"); uri != "" {
        return uri
    }
    return defaultMongoURI
}

func newMongoClient(lc fx.Lifecycle, logger zerolog.Logger) (*mongo.Client, error) {
    connectCtx, cancel := context.WithTimeout(context.Background(), mongoConnectTimeout)
    defer cancel()

    client, err := mongoadapter.Connect(connectCtx, mongoURI())
    if err != nil {
        return nil, err
    }

    lc.Append(fx.Hook{
        OnStop: func(ctx context.Context) error {
            if err := mongoadapter.Disconnect(ctx, client); err != nil {
                return err
            }
            logger.Info().Msg("mongo client disconnected")
            return nil
        },
    })

    logger.Info().Str("uri", mongoURI()).Msg("mongo client connected")
    return client, nil
}

func newProductRepository(
    lc fx.Lifecycle,
    logger zerolog.Logger,
) (ports.ProductRepository, error) {
    switch productRepositoryKind() {
    case "memory":
        logger.Info().Msg("using in-memory product repository")
        return inmemory.NewProductRepository(), nil
    default:
        client, err := newMongoClient(lc, logger)
        if err != nil {
            return nil, err
        }
        return mongoadapter.NewProductRepository(client), nil
    }
}

func newHTTPHandler(h *httpadapter.Handler) http.Handler { return h.Routes() }

func newHTTPServer(handler http.Handler) *http.Server {
    return &http.Server{
        Addr:              httpAddr,
        Handler:           handler,
        ReadHeaderTimeout: 5 * time.Second,
    }
}

func registerHTTPServer(
    lc fx.Lifecycle,
    logger zerolog.Logger,
    server *http.Server,
    shutdowner fx.Shutdowner,
) {
    lc.Append(fx.Hook{
        OnStart: func(ctx context.Context) error {
            go func() {
                logger.Info().Str("addr", server.Addr).Msg("http server starting")
                if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
                    logger.Error().Err(err).Msg("http server error")
                    _ = shutdowner.Shutdown(fx.ExitCode(1))
                }
            }()
            return nil
        },
        OnStop: func(ctx context.Context) error {
            shutdownCtx, cancel := context.WithTimeout(ctx, shutdownTimeout)
            defer cancel()
            if err := server.Shutdown(shutdownCtx); err != nil {
                return err
            }
            logger.Info().Msg("http server stopped")
            return nil
        },
    })
}

func appOptions(overrides ...fx.Option) []fx.Option {
    gin.SetMode(gin.ReleaseMode)

    opts := []fx.Option{
        fx.WithLogger(func() fxevent.Logger { return fxevent.NopLogger }),

        fx.Provide(
            newLogger,
            asIDGenerator,
            newProductRepository,
            application.NewProductCommandService,
            application.NewProductQueryService,
            httpadapter.NewHandler,
            newHTTPHandler,
            newHTTPServer,
        ),

        fx.Invoke(registerHTTPServer),
    }
    return append(opts, overrides...)
}

func newApp(overrides ...fx.Option) *fx.App { return fx.New(appOptions(overrides...)...) }

func main() { newApp().Run() }

5. Concurrency Control, Locking & Transactions

Locking

1. Pessimistic Locking

The database sets a lock to the record during field/read so that another transaction cannot update the same record.

Common in Oracle, MySQL:

-- SQL Pessimistic Locking
SELECT * FROM products WHERE id = 1 FOR UPDATE;

2. Optimistic Locking

Detect concurrent updates to the same document via a version field. If the version field is different, then it is a conflict.

Transactions

What it does: Makes several reads/writes commit or abort all together.

session.WithTransaction() from the mongo driver library is used for transaction management.

How WithTransaction Works:

Flow was roughly:

  1. Start a mongo session (client.StartSession()).
  2. Run your callback inside session.WithTransaction.
  3. Pass the session context (SessionContext) into repo methods like Save(txCtx, p).
  4. Mongo tracks reads/writes in that transaction.
  5. On commit, Mongo checks: “Did anyone else change data I touched?”
    • No → commit
    • Yes → abort (WriteConflict)
  6. The Go driver retries the whole callback on transient errors (including some conflicts).
  7. This requires replica set.

When Transactions Matter:
A transaction would only matter if one use case did multiple coordinated writes, e.g.: when adding a new product to the inventory, then the transaction must contain all these operations — insert product, insert audit log, update inventory. Then WithTransaction() in MongoDB ensures all three succeed or none do.

If you have only one operation (e.g. insert product), then a transaction is usually unnecessary overhead.

Application-Level Optimistic Locking

This is used for concurrent updates to same document. Mongo does not give you this automatically. You add it:

// read product, version = 3
db.products.updateOne(
  { _id: "abc", version: 3 },
  { $set: { price: 2000, version: 4 } }
)
// if matchedCount == 0 → someone else updated first → retry or 409 Conflict

Pattern:

  1. Read doc + version
  2. Apply business logic
  3. Update with filter { _id, version: old }
  4. On no match → conflict → retry or return error

That is your optimistic locking, separate from Mongo transactions.


6. Integration Tests with Testcontainers & Testify

Adding Dependencies

go get github.com/testcontainers/testcontainers-go github.com/testcontainers/testcontainers-go/modules/mongodb github.com/stretchr/testify

testify is required for go test assertions

Core Integration Testing Primitives

t.Cleanup(fn)

t.Cleanup(fn) registers a function to run after the test finishes, whether it passes or fails. It’s like defer, but tied to the test lifecycle instead of the function scope. They run in reverse order (LIFO):

t.Cleanup(func() {
    require.NoError(t, mongoC.Terminate(ctx))
})
// ...
t.Cleanup(func() {
    require.NoError(t, mongoadapter.Disconnect(ctx, client))
})
// ...
t.Cleanup(srv.Close)

t.Helper()

t.Helper() = provides cleaner failure line numbers in test output.

t.Run()

t.Run() defines a subtest inside a parent test function:

func TestProductAPI(t *testing.T) {
    env := setupTestEnv(t)

    t.Run("add product", func(t *testing.T) {
        // subtest 1
    })

    t.Run("list products", func(t *testing.T) {
        // subtest 2
    })
}

Why use this? Mongo/container startup runs once in the parent; subtests reuse env. That’s why your suite is faster than two separate top-level tests each calling setupTestEnv.

You can run one subtest:

go test -v ./tests/... -run 'TestProductAPI/list_products'

Subtests can call t.Parallel() if they’re independent. Yours share one DB, so they run sequentially.

The Complete Integration Test Suite

// tests/product_api_test.go
package integration_test

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "net/http"
    "net/http/httptest"
    "testing"
    "time"

    httpadapter "cmd/product-management/adapters/in/http"
    mongoadapter "cmd/product-management/adapters/out/mongo"
    "cmd/product-management/application"
    "cmd/product-management/application/ports"
    product "cmd/product-management/domain"

    "github.com/gin-gonic/gin"
    "github.com/google/uuid"
    "github.com/rs/zerolog"
    "github.com/stretchr/testify/require"
    "github.com/testcontainers/testcontainers-go/modules/mongodb"
)

type uuidIDs struct{}

func (uuidIDs) NewID() product.ID { return product.ID(uuid.NewString()) }

type testEnv struct {
    srv *httptest.Server
}

func setupTestEnv(t *testing.T) *testEnv {
    t.Helper()

    gin.SetMode(gin.TestMode)
    ctx := context.Background()

    // 1. Start MongoDB container
    mongoC, err := mongodb.Run(ctx, "mongo:7")
    require.NoError(t, err)
    t.Cleanup(func() {
        require.NoError(t, mongoC.Terminate(ctx))
    })

    uri, err := mongoC.ConnectionString(ctx)
    require.NoError(t, err)

    // 2. Connect client
    connectCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
    defer cancel()

    client, err := mongoadapter.Connect(connectCtx, uri)
    require.NoError(t, err)
    t.Cleanup(func() {
        require.NoError(t, mongoadapter.Disconnect(ctx, client))
    })

    // 3. Assemble application graph
    repo := mongoadapter.NewProductRepository(client)
    var idGen ports.IDGenerator = uuidIDs{}
    commands := application.NewProductCommandService(repo, idGen)
    queries := application.NewProductQueryService(repo)
    handler := httpadapter.NewHandler(commands, queries, zerolog.Nop())

    // 4. Start ephemeral test HTTP server
    srv := httptest.NewServer(handler.Routes())
    t.Cleanup(srv.Close)

    return &testEnv{srv: srv}
}

func (e *testEnv) addProduct(t *testing.T, name string, price int64) string {
    t.Helper()

    resp, err := http.Post(
        e.srv.URL+"/products",
        "application/json",
        bytes.NewBufferString(fmt.Sprintf(`{"name":%q,"price":%d}`, name, price)),
    )
    require.NoError(t, err)
    defer resp.Body.Close()
    require.Equal(t, http.StatusCreated, resp.StatusCode)

    var body map[string]string
    require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
    require.NotEmpty(t, body["id"])
    return body["id"]
}

func (e *testEnv) listProducts(t *testing.T) []application.ProductView {
    t.Helper()

    resp, err := http.Get(e.srv.URL + "/products")
    require.NoError(t, err)
    defer resp.Body.Close()
    require.Equal(t, http.StatusOK, resp.StatusCode)

    var body struct {
        Products []application.ProductView `json:"products"`
    }
    require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
    return body.Products
}

func TestProductAPI(t *testing.T) {
    env := setupTestEnv(t)

    t.Run("add product", func(t *testing.T) {
        id := env.addProduct(t, "Widget", 1299)
        require.NotEmpty(t, id)
    })

    t.Run("list products", func(t *testing.T) {
        id := env.addProduct(t, "Gadget", 999)
        products := env.listProducts(t)

        var got *application.ProductView
        for i := range products {
            if products[i].ID == id {
                got = &products[i]
                break
            }
        }
        require.NotNil(t, got, "added product not found in list response")
        require.Equal(t, "Gadget", got.Name)
        require.Equal(t, int64(999), got.Price)
    })
}

7. Summary & Key Architectural Takeaways

Explore Language Fundamentals

Want to learn more about Go language mechanics, goroutines, channels, memory models, and workspace dependency resolution?

👉 Check out: Go Fundamentals — The Complete Guide to Core Concepts, Concurrency, and Tooling.