Skip to main content
Published
MonorepoArchitectureFira.NET Aspire

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.

fira solution shape (illustrative)txt
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
One repo for the platform spine. Services stay separate processes with shared contracts.
AppHost/Program.cscsharp
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();
Aspire keeps the monorepo honest locally: the graph you run is the graph you mean.

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.
BuildingBlocks/Events/DomainEvent.cscsharp
public sealed record DomainEvent<T>(
  string EventId,
  string Type,
  DateTimeOffset OccurredAt,
  T Payload
);
Contracts stay small. Producers and consumers agree here, not in Slack.

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.

notes/fira-event-flow.tsts
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;
The spine in one glance.

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.

notes/monorepo-decision.tsts
type BackendCase =
  | "distributed-backend-spine" // Fira
  | "single-service-api";       // skip the monorepo
 
function shouldMonorepo(c: BackendCase) {
  return c === "distributed-backend-spine";
}
The decision for Fira-shaped work.

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.

More writing

Availability

Open to opportunities

Open to senior product engineer roles with real ownership across the stack. Security-minded by default.

UK-based · Remote / hybrid · Permanent or contract