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:
platform-api: the main HTTP and WebSocket APImcp-api: exposes workflow nodes as MCP toolswebhook-api: the public edge for trigger webhookstask-worker: consumes queued workflow runs, when queue mode is onevent-relay-worker: drains the transactional outboxplatform-api-migration: one-shot migrations before startup
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.sqlThe layers map onto the usual vocabulary, without making a ceremony of it:
- DDD:
domain/holds the entity, its errors and the repository interface. It imports nothing from the other layers. - Hexagonal: the repository interface is a port, and
postgres/is the adapter. Swapping storage means writing a new adapter, not touching use cases. - CQRS, the light version: every write is a command struct with its own
Validate()and lives in its own file as one use case. Reads are separate query functions that map rows straight into response shapes. There is no command bus or mediator. A use case is a method you call.
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 allowedWhatever 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.APIKeyServiceThe rules this gives you, with no linter and no architecture test:
- Modules never share repositories. If you need another module's data, you call its service through the contract.
- Modules communicate through service interfaces injected in
bootstrap, or through domain events. - A module's routes, handlers, persistence and migrations live entirely in its own directory.
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
- Put module internals in
internal/on day one. Retrofitting boundaries into a codebase that already crosses them is painful. - Make one file the only wiring surface per module. If you can't tell what a module depends on from one struct, it depends on too much.
- Keep CQRS light. Separate commands and queries in your files and your head. You don't need a bus to do that.
- Scale by splitting binaries out of the same code. Splitting the codebase is a decision you can still make later, and by then the module boundaries will show you where to cut.