Monorepo vs Multi-repo for Microservices
Overview
In a microservices architecture, your repository strategy significantly impacts development efficiency, CI/CD, and team collaboration. The two main approaches are Monorepo and Multi-repo (Polyrepo), each with different trade-offs.
Monorepo
An approach where all microservices and libraries are managed in a single repository.
my-platform/
├── services/
│ ├── OrderService/
│ │ ├── src/
│ │ ├── tests/
│ │ └── OrderService.csproj
│ ├── UserService/
│ │ ├── src/
│ │ ├── tests/
│ │ └── UserService.csproj
│ └── PaymentService/
│ ├── src/
│ ├── tests/
│ └── PaymentService.csproj
├── shared/
│ ├── Common.Contracts/
│ ├── Common.Infrastructure/
│ └── Common.Testing/
├── Directory.Build.props
├── Directory.Packages.props
└── MyPlatform.slnx
Advantages
| Aspect | Description |
|---|---|
| Easy code sharing | Common libraries and contracts can be used via direct project references |
| Atomic changes | Cross-service changes can be made in a single PR |
| Unified version management | Package versions centrally managed with Directory.Packages.props |
| Easier refactoring | API contract changes can be applied across all services at once |
| Consistent developer experience | Coding standards, linting, and CI configurations are unified |
Disadvantages
| Aspect | Description |
|---|---|
| Repository bloat | Codebase grows large, slowing clones and builds |
| CI complexity | Requires mechanisms to build/deploy only affected services |
| Access control difficulty | Fine-grained per-service access control is challenging |
| Coupling risk | Independent deployment becomes harder; tight coupling may creep in |
| Tooling dependency | Dedicated build tools (Nx, Bazel, etc.) may be needed |
Multi-repo
An approach where each microservice is managed in its own independent repository.
# Individual repositories
order-service/
├── src/
├── tests/
├── Dockerfile
├── OrderService.csproj
└── OrderService.sln
user-service/
├── src/
├── tests/
├── Dockerfile
├── UserService.csproj
└── UserService.sln
common-contracts/ # Published as NuGet package
├── src/
├── Common.Contracts.csproj
└── Common.Contracts.sln
Advantages
| Aspect | Description |
|---|---|
| Independent deployment | Each service can be built and deployed fully independently |
| Clear ownership | Each team owns their repository with clear responsibility boundaries |
| Simple CI/CD | Each repository's CI pipeline is small and simple |
| Flexible access control | Permissions can be configured per repository |
| Technology freedom | Different frameworks or versions can be chosen per service |
Disadvantages
| Aspect | Description |
|---|---|
| Cumbersome code sharing | Common libraries must be packaged as NuGet packages |
| Cross-cutting change cost | Requires PRs across multiple repositories with high coordination cost |
| Version inconsistency | Dependency package versions may diverge across services |
| Code duplication | Common logic tends to be duplicated across repositories |
| Onboarding difficulty | Hard for newcomers to understand which repository contains what |
Comparison Matrix
| Aspect | Monorepo | Multi-repo |
|---|---|---|
| Code sharing | ✅ Easy via project references | ⚠️ Requires NuGet packaging |
| Atomic changes | ✅ Single PR | ❌ Multi-repo coordination needed |
| Independent deployment | ⚠️ Complex CI configuration | ✅ Naturally independent |
| Team autonomy | ⚠️ Potential conflicts | ✅ Fully independent |
| CI/CD complexity | ⚠️ Impact detection required | ✅ Simple |
| Onboarding | ✅ Everything in one place | ⚠️ Distributed |
| Tooling requirements | ⚠️ May need specialized tools | ✅ Standard tools suffice |
| Refactoring | ✅ Bulk changes are easy | ❌ High coordination cost |
| Repository size | ❌ Grows large | ✅ Stays small |
| Access control | ❌ Hard to restrict | ✅ Per-repository control |
.NET Monorepo Best Practices
1. Shared Settings with Directory.Build.props
Place a Directory.Build.props at the repository root to apply common settings across all projects.
<!-- Directory.Build.props -->
<Project>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup>
</Project>
2. Central Package Management (CPM)
Centrally manage NuGet package versions with Directory.Packages.props.
<!-- Directory.Packages.props -->
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" />
<PackageVersion Include="Serilog" Version="4.2.0" />
<PackageVersion Include="FluentValidation" Version="11.11.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
</ItemGroup>
</Project>
Individual projects can omit version numbers.
<!-- OrderService.csproj -->
<ItemGroup>
<PackageReference Include="Serilog" />
<PackageReference Include="FluentValidation" />
</ItemGroup>
3. Change Impact Detection (GitHub Actions)
Build CI/CD pipelines that only build and deploy affected services.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
order-service: ${{ steps.changes.outputs.order-service }}
user-service: ${{ steps.changes.outputs.user-service }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: changes
with:
filters: |
order-service:
- 'services/OrderService/**'
- 'shared/**'
user-service:
- 'services/UserService/**'
- 'shared/**'
build-order-service:
needs: detect-changes
if: needs.detect-changes.outputs.order-service == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- run: dotnet build services/OrderService/OrderService.csproj
- run: dotnet test services/OrderService/tests/
4. Leveraging Solution Files
Use the .slnx format (available from .NET 9) and leverage per-service filtering.
<!-- MyPlatform.slnx (full solution) -->
<Solution>
<Folder Name="/services/OrderService/">
<Project Path="services/OrderService/OrderService.csproj" />
<Project Path="services/OrderService/tests/OrderService.Tests.csproj" />
</Folder>
<Folder Name="/services/UserService/">
<Project Path="services/UserService/UserService.csproj" />
<Project Path="services/UserService/tests/UserService.Tests.csproj" />
</Folder>
<Folder Name="/shared/">
<Project Path="shared/Common.Contracts/Common.Contracts.csproj" />
<Project Path="shared/Common.Infrastructure/Common.Infrastructure.csproj" />
</Folder>
</Solution>
You can also provide per-service solution files for individual development.
<!-- services/OrderService/OrderService.slnx (individual development) -->
<Solution>
<Project Path="OrderService.csproj" />
<Project Path="tests/OrderService.Tests.csproj" />
<Project Path="../../shared/Common.Contracts/Common.Contracts.csproj" />
</Solution>
5. Shared Library Design Guidelines
Shared libraries in a monorepo should follow these principles.
shared/
├── Common.Contracts/ # DTOs, event definitions (no dependencies)
├── Common.Infrastructure/ # Cross-cutting concerns (logging, auth, health checks)
└── Common.Testing/ # Test helpers, custom factories
- Contracts should consist only of POCOs with no external dependencies
- POCO (Plain Old CLR Object) refers to a pure C# class (or record) that does not depend on any specific framework or base class. Because POCOs have no references to external libraries or framework-specific attributes, they can be referenced from any project with minimal overhead, avoiding serialization and versioning issues
- Infrastructure should remain a thin abstraction layer, overridable by each service
- Do not place service-specific logic in shared libraries
// ✅ Suitable for sharing: event contracts
namespace Common.Contracts.Events;
public record OrderCreatedEvent(
Guid OrderId,
Guid CustomerId,
decimal TotalAmount,
DateTime CreatedAt);
// ❌ Not suitable for sharing: service-specific business logic
// namespace Common.Services;
// public class OrderPricingCalculator { ... }
.NET Multi-repo Best Practices
1. Code Sharing via NuGet Packages
Publish common libraries as NuGet packages for consumption by each service.
<!-- Common.Contracts.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<PackageId>MyCompany.Common.Contracts</PackageId>
<Version>2.1.0</Version>
<Description>Shared contracts and DTOs for microservices</Description>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
</PropertyGroup>
</Project>
Use GitHub Packages or Azure Artifacts as private NuGet feeds.
<!-- nuget.config -->
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
<add key="MyCompanyFeed"
value="https://pkgs.dev.azure.com/myorg/_packaging/myFeed/nuget/v3/index.json" />
</packageSources>
</configuration>
2. Template Repositories
Prepare template repositories to standardize new microservice creation.
service-template/
├── src/
│ ├── Program.cs
│ ├── appsettings.json
│ └── MyService.csproj
├── tests/
│ └── MyService.Tests.csproj
├── .github/
│ └── workflows/
│ └── ci.yml
├── Dockerfile
├── .editorconfig
├── nuget.config
└── README.md
Packaging as a dotnet new template is also effective.
dotnet new install MyCompany.ServiceTemplate
dotnet new myservice -n InventoryService
3. Contract Testing
In a multi-repo setup, Contract Testing is crucial for ensuring API contracts between services don't break.
// Example of consumer-driven testing with Pact
public class OrderServiceConsumerTests
{
private readonly IPactBuilderV4 _pactBuilder;
public OrderServiceConsumerTests()
{
var pact = Pact.V4("OrderService", "UserService", new PactConfig());
_pactBuilder = pact.WithHttpInteractions();
}
[Fact]
public async Task GetUser_ReturnsExpectedUser()
{
_pactBuilder
.UponReceiving("a request for user details")
.WithRequest(HttpMethod.Get, "/api/users/123")
.WillRespond()
.WithStatus(HttpStatusCode.OK)
.WithJsonBody(new
{
id = "123",
name = "John Doe",
email = "john@example.com"
});
await _pactBuilder.VerifyAsync(async ctx =>
{
var client = new HttpClient
{
BaseAddress = ctx.MockServerUri
};
var response = await client.GetAsync("/api/users/123");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
});
}
}
4. Automated Dependency Updates with Renovate / Dependabot
In a multi-repo setup, dependency management is distributed, so introduce automated update tools.
// renovate.json
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:base"],
"nuget": {
"enabled": true
},
"packageRules": [
{
"matchPackagePatterns": ["MyCompany\\.Common\\..*"],
"automerge": true,
"automergeType": "pr"
}
]
}
Hybrid Approach
In real-world projects, a hybrid approach combining monorepo and multi-repo can be effective.
Hybrid Pattern Examples
| Pattern | Description |
|---|---|
| Platform + Domain separation | Common infrastructure in monorepo, domain services in multi-repo |
| Team-based monorepos | Each team manages their owned services in a monorepo |
| Shared libraries only monorepo | Shared code in monorepo, individual services in multi-repo |
Selection Guidelines
When Monorepo is a Good Fit
- Small teams (~20 people) working closely together
- Strong inter-service dependencies with frequent simultaneous changes
- High frequency of shared library changes
- Organization values unified development standards and toolchains
- Using .NET Aspire for unified orchestration
When Multi-repo is a Good Fit
- Large organizations with multiple autonomous teams
- Services developed and deployed independently by each team
- Different technology stacks or release cycles needed
- Strict access control or compliance requirements
- External vendors or partners developing services
Decision Flowchart
Summary
- Monorepo excels at code sharing and atomic changes but faces CI/CD complexity and repository bloat challenges
- Multi-repo excels at team autonomy and independent deployment but faces cross-cutting change and version management challenges
- .NET features like
Directory.Build.props, Central Package Management, and.slnxpowerfully support monorepo operations - For multi-repo, NuGet packaging, template repositories, and Contract Testing are key
- There is no single right answer: Choose the appropriate strategy based on team size, organizational structure, and service dependencies