A Go Modular Monolith with DDD, CQRS and Hexagonal Architecture

BlockNext is a multi-tenant AI workflow platform: workflows, triggers, a task runner, credentials with OAuth, an MCP server, organizations, API keys, notifications. On a whiteboard that is easily a dozen microservices. In the repository it is one Go codebase, with one database, that builds six binaries.

This post is about that layout: why a modular monolith, what a module looks like, and how to keep the "modular" part honest once the codebase grows.

Why not microservices

The usual arguments for microservices are independent deploys, independent scaling and team autonomy. For a small team building one product, the first and last don't apply. The middle one, scaling, doesn't need separate codebases. It needs separate processes.

So BlockNext splits binaries, not codebases. The same module code is assembled into different entry points:

Each binary is wired in a bootstrap package with plain constructor calls. There is no DI framework and no service locator. If the webhook edge needs more instances, you scale webhook-api. You don't need a new repository, a new CI pipeline or a network hop between modules that used to be a function call.

Where it started

Earlier I worked out the shape in a small skeleton, go-modular-monolith: users, tenants and todos, each a module with four layers.

internal/
├── common/
│   ├── domain/           base entity, result type
│   ├── infrastructure/   database connection
│   └── presentation/     JSON response helpers
├── tenants/
├── todos/
│   ├── domain/           Todo, TodoRepository (interface), errors
│   ├── application/      one file per use case
│   ├── infrastructure/   TodoRepository (GORM)
│   ├── presentation/     one file per HTTP handler
│   └── main.go           Register(app, db)
└── users/

Each module had one Register function, and main just called them in order:

func main() {
	// ...config, fiber app, database...

	usersModule.Register(app, db)
	todosModule.Register(app, db)
	tenantsModule.Register(app, db)
}

And each module kept its tables in its own Postgres schema. That's a cheap way to make "who owns this table" obvious in the database too:

func (Todo) TableName() string {
	return "todos.todos" // every module gets its own Postgres schema
}

The skeleton got the layering right. But the boundaries were only a convention: nothing stopped todos from importing users/infrastructure and querying the users table directly. In a three-module demo that's fine. In an eighteen-module product, every shortcut like that becomes a hidden dependency.

What a module looks like now

In BlockNext a module is a directory with exactly two public surfaces, module.go and contract/. Everything else is inside internal/:

modules/apikeys/
├── module.go                 # the only wiring surface: NewModule, Register
├── contract/api_key.go       # what other modules are allowed to see
├── README.md
└── internal/
    ├── domain/apikeys/       entity, errors, Repository interface, scopes
    ├── application/apikeys/  services exported to other modules
    ├── usecases/apikeys/     create.go, delete.go, regenerate.go, get_all.go
    ├── postgres/apikeys/     Repository implementation (database/sql)
    ├── http/                 routes + one handler per endpoint
    └── migrations/           *.up.sql / *.down.sql

The layers map onto the usual vocabulary, without making a ceremony of it:

module.go is the only place where the parts are put together. Its Dependencies struct says exactly what the module needs from the outside world, and the exported fields of Module say exactly what it offers:

type Dependencies struct {
	DB                 *sql.DB
	TransactionManager database.TransactionManager
}

type Module struct {
	APIKeyValidator commonAuth.APIKeyValidator[apiKeysDomainAPIKeys.Scope]
	APIKeyService   apiKeysApplicationAPIKeys.APIKeyService

	useCases *apiKeysUseCases.Services
}

func NewModule(deps Dependencies) *Module {
	repository := apiKeysPostgresAPIKeys.NewAPIKeyRepository(deps.DB)
	useCases := apiKeysUseCases.NewServices(apiKeysUseCases.ServiceDependencies{
		TransactionManager: deps.TransactionManager,
		ApiKeyRepository:   repository,
	})

	return &Module{
		APIKeyValidator: apiKeysApplicationAPIKeys.NewAPIKeyValidator(repository),
		APIKeyService:   apiKeysApplicationAPIKeys.NewAPIKeyService(repository),
		useCases:        useCases,
	}
}

func (m *Module) Register(router fiber.Router, auth *commonAuth.AuthMiddleware, cache *cachemiddleware.Middleware) {
	apiKeysHTTP.RegisterRoutes(router, auth, cache, m.useCases)
}

Let the compiler enforce the boundaries

This is the part I like most, and it is plain Go. A package under a directory named internal can only be imported from inside the tree rooted at its parent. So modules/apikeys/internal/... can be used by modules/apikeys, and by nothing else.

If the workflows module tries to reach into the API keys repository, the build fails:

// in modules/workflows/...
import ".../modules/apikeys/internal/postgres/apikeys"

// use of internal package .../modules/apikeys/internal/postgres/apikeys not allowed

Whatever another module legitimately needs is re-exported from contract/, usually as type aliases. The contract is short, reviewed on purpose, and it is the whole public API of the module:

package contract

type Scope = apikeysDomainAPIKeys.Scope

const ScopeMCPInvoke = apikeysDomainAPIKeys.ScopeMCPInvoke
const ScopeWorkflowsTrigger = apikeysDomainAPIKeys.ScopeWorkflowsTrigger

type APIKeyService = apikeysApplicationAPIKeys.APIKeyService

The rules this gives you, with no linter and no architecture test:

One database, owned per module

There is one Postgres database, but each module owns its migrations and has its own migration tracking table. A one-shot migration container runs them all before the stack starts. Persistence is database/sql with lib/pq and a few shared helpers. The early skeleton used GORM. Without an ORM every query is plain SQL in the code, visible in code review.

Events that must not be lost go through a transactional outbox in the same database, written in the same transaction as the change that caused them. I wrote more about that, and the rest of the "no Redis by default" setup, in Running a Workflow Platform on 100 MB of RAM Without Redis.

What I would tell someone starting one

← All posts