Monorepos on Fira
Why Fira lives in a backend monorepo: Keycloak, Kafka, Redis, Docker, and .NET Aspire as one coherent spine, with shared contracts and clear service boundaries. Still in progress.
I do not start every product in a monorepo. I reach for one when shared contracts, shared standards, and a shared delivery graph matter more than the comfort of a single project folder.
Fira is the clearest example in my own work, and it is still in progress. It is a self-hosted backend spine: Keycloak for identity, Kafka for events, Redis for cache and coordination, Docker for packaging, and .NET Aspire for local orchestration. That is several deployable units that must stay coherent. A monorepo is how I keep them honest.
What Fira needs from a monorepo
Self-hosted platforms fail quietly when identity, APIs, and consumers evolve in disconnected repositories. Kafka topic names drift. Token validation forks. Redis key conventions become tribal knowledge. Docker Compose folklore replaces a real dependency graph.
Fira needs the opposite. One change set can update a contract and the services that depend on it. Aspire can prove the topology on a laptop before Docker takes it further. Analytics and feature flags can sit in the spine from day one instead of being bolted onto whichever service somebody remembered.
- Keycloak as the identity source of truth across services.
- Kafka topics with consumer groups per worker responsibility.
- Redis for hot reads and short-lived coordination.
- .NET Aspire AppHost for local ports, health, and wiring.
Solution shape
The monorepo is a solution of services, not a pile of unrelated apps. AppHost composes the graph. Api and Workers stay separate processes. BuildingBlocks holds shared contracts and auth helpers that should stay boring and stable.
src/
AppHost/ # .NET Aspire composition
Identity/ # Keycloak config + helpers
Api/ # ASP.NET domain APIs
Workers/ # Kafka consumers
BuildingBlocks/ # shared contracts, auth helpers
docker/ # images and local overlays
var builder = DistributedApplication.CreateBuilder(args);
var redis = builder.AddRedis("redis");
var kafka = builder.AddKafka("kafka");
var api = builder.AddProject<Projects.Api>("api")
.WithReference(redis)
.WithReference(kafka);
builder.AddProject<Projects.Workers>("workers")
.WithReference(kafka)
.WithReference(redis)
.WithReference(api);
builder.Build().Run();
Shared contracts without a kitchen sink
BuildingBlocks is useful until it becomes a dumping ground. Shared code should hold token validation helpers, event envelope types, and stable identifiers. Feature logic stays in Api or Workers.
Project references must stay intentional. If every service depends on everything, you have a monorepo-shaped monolith. The point is deployable units with a shared spine, not one DLL with five entry points.
- Shared contracts reduce integration theatre between producers and consumers.
- Aspire AppHost documents dependency order better than a README.
- Dockerfiles stay next to the services they build.
public sealed record DomainEvent<T>(
string EventId,
string Type,
DateTimeOffset OccurredAt,
T Payload
);
Event flow across the repo
A typical Fira path starts at Keycloak. The API validates tokens and accepts domain commands. Commands that need fan-out publish to Kafka. Workers consume topics, update state, and write side effects. Redis absorbs hot lookups where strong consistency is not required. Feature flags gate incomplete paths while the platform evolves.
That flow is easier to keep aligned when the producer, consumer, and envelope type live in one reviewable change.
const firaFlow = [
"authenticate against Keycloak",
"API accepts a domain command",
"publish event to Kafka when fan-out is needed",
"worker consumes and applies side effects",
"Redis covers hot reads and short locks",
] as const;
Gotchas I watch for
Monorepos hide coupling until CI is slow and nobody owns BuildingBlocks. Keep package APIs small. Avoid circular project references. Version mental models even when everything is linked locally.
The main Fira risk is every service taking a hard dependency on a kitchen-sink shared project. Shared code should be boring. Feature code should stay feature-local.
Tooling must match the graph. Clear project references. An AppHost that reflects production shapes closely enough to catch wiring mistakes early. Docker images built from the same repo layout you run locally.
When I choose this shape
I use a backend monorepo when identity, streaming, and APIs are meant to evolve together on a self-hosted stack. I skip it when I am building one app with no shared consumers and no distributed local topology to prove.
type BackendCase =
| "distributed-backend-spine" // Fira
| "single-service-api"; // skip the monorepo
function shouldMonorepo(c: BackendCase) {
return c === "distributed-backend-spine";
}
What I take forward
From Fira I take a simple lesson. Self-hosted backends need a monorepo spine if identity, events, and APIs are supposed to stay one platform. The folder structure is optional. The coherence is not.