12 KiB
Modular monolith architecture
This is the central documentation for Fleet's transition to a modular monolith architecture. It provides an overview of the approach and the current status.
Overview
Fleet's Go codebase has grown to over 600,000 lines of code across 2,300+ Go files. To improve maintainability, team scalability, and code quality, we are transitioning from a traditional layered architecture to a modular monolith with well-defined bounded contexts.
What is a modular monolith?
A modular monolith is an architectural pattern where:
- Code is organized into cohesive, loosely-coupled bounded contexts
- Each bounded context owns its complete vertical slice: handlers → service logic → data access
- Bounded contexts communicate through well-defined public interfaces, not internal implementation details
- The application still deploys as a single binary
This approach provides the benefits of strong boundaries (clear ownership, better testability, reduced coupling) without the operational complexity of distributed systems.
Industry precedents
| Company | Codebase | Approach |
|---|---|---|
| GitLab | Ruby, 2.2M+ LOC | Modular monolith with bounded contexts |
| Kubernetes | Go, 500K+ LOC | 30+ controllers in single binary with logical separation |
| Shopify | Ruby, 3M+ LOC | Modular monolith with component boundaries |
Current status
Bounded contexts
| Context | Status | ADR | Implementation |
|---|---|---|---|
| Activity (audit) | Complete | ADR-0007 | #36452 |
Learnings from the Activity pilot
The Activity bounded context pilot validated the modular monolith approach and surfaced several key learnings:
internal/directory is essential: Go's compiler-enforcedinternaldirectory provides a stronger guarantee than convention-based rules alone. It prevents accidental imports of implementation details without relying solely on code review or architecture tests.- Anti-corruption layers (ACLs) bridge legacy code: The
server/acl/activityacl/adapter translates between legacy Fleet types and bounded context domain types, keeping the context decoupled from the monolith. ACLs are expected to be temporary; they can be removed once upstream code exposes clean public APIs. - Bootstrap pattern simplifies wiring: A single
bootstrap.New()function is the only public entry point for instantiating the context. This keeps dependency injection centralized and makes it straightforward to wire the context into both production and test code. - Dedicated CI workflows reduce noise: Giving the bounded context its own GitHub Actions workflow (triggered only when its files change) avoids unnecessary test runs and isolates flaky tests from the rest of the codebase.
Candidates for future extraction
These bounded contexts are candidates for future extraction, based on domain boundaries and team ownership:
- Identity - Users, sessions, and authentication
- Vulnerabilities - Vulnerability detection and management
- Software - Software inventory, installation, and updates
- Policies - Policy definitions and compliance
- Scripts - Script execution and batch activities
- Reports - Report management and scheduling
Architecture principles
Bounded context ownership
Each bounded context owns its complete vertical slice.
Key principles:
- Other contexts can only import the root package (public interface), not the
service/ormysql/subdirectories - The context that owns the API endpoint is responsible for orchestrating operations that span multiple bounded contexts
Database guidelines
| Guideline | Details |
|---|---|
| Single shared database | All contexts share one MySQL database |
| Exclusive write ownership | Each context owns writing to its tables exclusively |
| Table prefixes | Tables use context name as prefix (e.g., activity_past, software_titles) |
| Cross-context reads allowed | Joins for read operations are permitted but indicate coupling |
| No cross-context transactions | Each transaction must be scoped to a single context's tables |
Communication patterns
- Use public service layer methods only (e.g.,
activity.NewActivity()) - Never call another context's datastore methods directly
- Async communication patterns to be defined in future ADR
Architectural enforcement
We enforce boundaries using architecture tests (arch_test.go) that validate import restrictions
for every package in the bounded context. These tests use the server/archtest package and define
an allowlist of Fleet dependencies per package. For example, the internal/service can import the context's own
public packages and shared platform packages. The internal directory provides an additional layer
of enforcement at the Go compiler level, preventing any code outside the bounded context from
importing implementation details.
These tests run as part of the regular CI test suite.
Reference patterns
The following patterns emerged from the Activity bounded context pilot. Bounded context owners are free to adopt these if they find them useful. They are not requirements.
Directory structure
Use Go's internal directory to enforce public vs private boundaries:
/server/{context}/
├── bootstrap/ # Public: dependency injection entry point
│ └── bootstrap.go
├── api/ # Public: service interface and request/response types
│ └── service.go
└── internal/ # Private: implementation details (cannot be imported externally)
├── types/ # Internal interfaces (e.g., Datastore)
├── service/ # Service implementation and HTTP handlers
├── mysql/ # Database implementation
└── tests/ # Integration tests
The internal directory is enforced by the Go compiler. Code outside the bounded context cannot import it.
The bootstrap package implements the composition root pattern. It is the single place where all dependencies are wired together. Code outside the bounded context calls bootstrap.New() to instantiate the fully-configured service.
Anti-corruption layer (ACL)
When a bounded context needs data from another context (or legacy Fleet code), you may use an ACL to translate between domains. This isolates the bounded context from external types and provides a single integration point.
ACLs are temporary by design. Once the dependency exposes a clean public API that does not bring in large coupling or transitive dependencies, the ACL can be removed and replaced with a direct import.
Location: /server/acl/{context}acl/
// FleetServiceAdapter is an anti-corruption layer that translates between
// the legacy Fleet user types and the activity bounded context's domain types.
type FleetServiceAdapter struct {
svc fleet.UserLookupService // External dependency
}
func (a *FleetServiceAdapter) UsersByIDs(ctx context.Context, ids []uint) ([]*activity.User, error) {
users, err := a.svc.UsersByIDs(ctx, ids) // Call external service
// ... translate fleet.User to activity.User ...
return result, nil
}
Benefits:
- Single point of integration with external code
- Bounded context remains decoupled from external types
- Easy to replace when a clean API becomes available
Dependency injection for authentication and authorization
Bounded contexts should not own authentication or authorization. These are injected from outside.
Authentication (verifying who the user is) is injected as HTTP middleware. The bootstrap function returns a routes function that accepts the auth middleware:
// bootstrap/bootstrap.go returns a function that accepts auth middleware
func New(...) (api.Service, func(authMiddleware endpoint.Middleware) eu.HandlerRoutesFunc) {
// ...
routesFn := func(authMiddleware endpoint.Middleware) eu.HandlerRoutesFunc {
return service.GetRoutes(svc, authMiddleware)
}
return svc, routesFn
}
Authorization (checking permissions) is injected as a service dependency. The bounded context calls the authorizer but does not implement it:
// bootstrap/bootstrap.go receives authorizer as a parameter
func New(
dbConns *platform_mysql.DBConnections,
authorizer platform_authz.Authorizer, // Injected from outside
// ...
) {
svc := service.NewService(authorizer, ds, userProvider, logger)
}
Test organization
Unit tests: Colocated with implementation files, use mocks for all dependencies.
Integration tests: In internal/tests/ directory, use real database and HTTP server with mock auth.
Dedicated CI workflows
Each bounded context can have its own GitHub Actions workflow that only triggers when files in that context change. This provides two benefits:
- Reduced CI load: Tests for unrelated bounded contexts don't run on every pull request, speeding up the overall CI pipeline.
- Reduced noise from flaky tests: A flaky test in one bounded context no longer blocks or confuses contributors working in other areas.
The Activity bounded context uses .github/workflows/test-go-activity.yaml, which triggers on
changes to server/activity/**/*.go (plus shared platform packages and go.mod/go.sum). It
runs tests against multiple MySQL versions and uploads coverage separately with a
backend-activity flag. A nightly cron schedule runs an extended matrix of MySQL versions to
catch compatibility issues.
New bounded contexts should follow this pattern by creating a dedicated workflow that filters on their own file paths.
Glossary
| Term | Definition |
|---|---|
| Bounded context | A DDD concept representing a cohesive domain with clear boundaries. In Fleet, a group of Go packages under a common directory. |
| Cross-cutting concern | Functionality that affects multiple parts of the system (e.g., activity logging, authentication). |
| Ubiquitous language | Using the same terminology consistently across code and business domains. |
| Vertical slice | The complete implementation stack for a feature: handlers → service → datastore → database. |
FAQ
How do I handle functionality that spans multiple contexts?
The context that owns the API endpoint orchestrates the operation. If writes to multiple contexts are needed, perform them as separate transactions. Use compensating transactions (Saga pattern) only as a last resort for critical consistency requirements.
What happens if I need to import implementation details from another context?
This is a sign that either:
- The functionality belongs in a shared package
- The bounded context boundaries need adjustment
- A public interface should be added to the other context
Discuss with the tech leads before bypassing boundaries.
What's the difference between a module and a bounded context?
A module is a generic term for organizing cohesive code. A bounded context has hard boundaries enforced through public APIs, clear ownership, and restricted imports. We use "bounded context" to emphasize that these are not just convenient code groupings, but intentional boundaries designed to allow teams to work independently as the codebase scales.