# Microservices in .NET: Getting Started Guide

> A .NET microservice system needs more than separately deployed APIs: each service owns its data, communication survives partial failure, and traces cross every boundary. This guide builds a small end-to-end system and makes the cost visible before you choose it.

Published: 2026-07-04. Last updated: 2026-07-14. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/microservices-dotnet-getting-started

Getting started with microservices in .NET takes more than separately deployed APIs: each service owns its own database, services communicate through a message broker or resilient HTTP calls, an API gateway gives clients one entry point, and OpenTelemetry traces cross every service boundary.
The example below builds those pieces into a small but complete system, then uses the same details to decide whether the architecture is justified.

Microservices look simple on an architecture diagram: a few boxes, a few arrows, each service doing one thing.
The real work hides in the details.

## When Do Microservices Make Sense?

Microservices split a system into independently deployable services, each owning its own data and communicating over the network.
They solve organizational scaling problems, not technical ones.
They make sense when:

- Multiple teams need to deploy independently
- Different parts of the system have different **scaling requirements**
- You need technology diversity (rarely a good reason on its own)

They don't make sense when:
- You have a small team (under 15 developers)
- Your domain isn't well understood
- You don't have the infrastructure to support them

Start with a [**modular monolith**](https://milanjovanovic.tech/blog/build-modular-monolith-dotnet-step-by-step). Extract microservices when you have specific reasons.
If you want the conceptual foundation first, I covered the core ideas in [**Understanding Microservices**](https://milanjovanovic.tech/blog/understanding-microservices-core-concepts-and-benefits).

## Architecture Overview

Three services, each owning its own database, coordinating through a message broker:

![Architecture diagram of Catalog, Ordering, and Shipping services, each with its own database, all publishing and consuming events through a RabbitMQ message broker](https://milanjovanovic.tech/blogs/articles/microservices-dotnet-getting-started/services-architecture.png)

Key principles:
- Each service owns its data (no shared databases)
- Services communicate via async messaging (events)
- Synchronous calls (HTTP/gRPC) only when necessary

## Building the Catalog Service

### Project Structure

```bash
dotnet new webapi -n Catalog.Api
dotnet add Catalog.Api package Microsoft.EntityFrameworkCore
dotnet add Catalog.Api package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet add Catalog.Api package MassTransit.RabbitMQ
```

### Domain

```csharp
public class Product
{
    public Guid Id { get; private set; }
    public string Name { get; private set; } = string.Empty;
    public string Description { get; private set; } = string.Empty;
    public decimal Price { get; private set; }
    public int StockQuantity { get; private set; }

    public static Product Create(string name, string description, decimal price, int stock)
    {
        return new Product
        {
            Id = Guid.NewGuid(),
            Name = name,
            Description = description,
            Price = price,
            StockQuantity = stock
        };
    }

    public bool HasSufficientStock(int quantity) => StockQuantity >= quantity;

    public void ReserveStock(int quantity)
    {
        if (!HasSufficientStock(quantity))
            throw new InvalidOperationException("Insufficient stock");

        StockQuantity -= quantity;
    }
}
```

### API Endpoints

```csharp
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<CatalogDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("CatalogDb")));

builder.Services.AddMassTransit(x =>
{
    x.AddConsumers(typeof(Program).Assembly);
    x.UsingRabbitMq((context, cfg) =>
    {
        cfg.Host(new Uri(
            builder.Configuration.GetConnectionString("RabbitMq")!));
        cfg.ConfigureEndpoints(context);
    });
});

var app = builder.Build();

app.MapGet("/api/products", async (CatalogDbContext db) =>
    Results.Ok(await db.Products.ToListAsync()));

app.MapGet("/api/products/{id:guid}", async (Guid id, CatalogDbContext db) =>
{
    var product = await db.Products.FindAsync(id);
    return product is null ? Results.NotFound() : Results.Ok(product);
});

app.MapPost("/api/products", async (
    CreateProductRequest request,
    CatalogDbContext db) =>
{
    var product = Product.Create(
        request.Name, request.Description, request.Price, request.Stock);

    db.Products.Add(product);
    await db.SaveChangesAsync();

    return Results.Created($"/api/products/{product.Id}", product);
});

app.Run();
```

## Building the Ordering Service

### Integration Events

Shared between services (typically as a NuGet package or shared project):

```csharp
// Shared contracts
public record OrderPlacedEvent(
    Guid OrderId,
    Guid CustomerId,
    List<OrderItemDto> Items,
    DateTime OccurredAt);

public record OrderItemDto(
    Guid ProductId,
    int Quantity,
    decimal UnitPrice);
```

### Order Placement

```csharp
app.MapPost("/api/orders", async (
    PlaceOrderRequest request,
    OrderingDbContext db,
    IPublishEndpoint publisher) =>
{
    var order = Order.Create(request.CustomerId);

    foreach (var item in request.Items)
    {
        order.AddLineItem(item.ProductId, item.Quantity, item.UnitPrice);
    }

    db.Orders.Add(order);
    await db.SaveChangesAsync();

    await publisher.Publish(new OrderPlacedEvent(
        order.Id,
        order.CustomerId,
        order.LineItems.Select(li => new OrderItemDto(
            li.ProductId, li.Quantity, li.UnitPrice)).ToList(),
        DateTime.UtcNow));

    return Results.Created($"/api/orders/{order.Id}", order);
});
```

There's a hidden problem in this code: the database write and the publish are two separate operations.
If the process crashes between them, the order exists but the event is lost.
For production, publish through the outbox pattern: store the event in the same database transaction as the order, then relay it to the broker afterwards.
It's one of the essential **data consistency patterns for microservices**.

## Service Communication

### Asynchronous (Events)

Placing an order writes to the Ordering database and publishes an event; the Shipping service reacts to it independently, with no direct call between the two:

![Sequence diagram: the client posts an order, the Ordering service saves it and publishes an OrderPlacedEvent to the message broker while returning 201 to the client, then the broker delivers the event to the Shipping service which creates a shipment](https://milanjovanovic.tech/blogs/articles/microservices-dotnet-getting-started/order-flow.png)

The Shipping service handles the `OrderPlacedEvent`:

```csharp
public class OrderPlacedConsumer : IConsumer<OrderPlacedEvent>
{
    private readonly ShippingDbContext _db;
    private readonly ILogger<OrderPlacedConsumer> _logger;

    public OrderPlacedConsumer(ShippingDbContext db, ILogger<OrderPlacedConsumer> logger)
    {
        _db = db;
        _logger = logger;
    }

    public async Task Consume(ConsumeContext<OrderPlacedEvent> context)
    {
        var message = context.Message;

        _logger.LogInformation(
            "Creating shipment for order {OrderId}", message.OrderId);

        var shipment = Shipment.Create(message.OrderId, message.CustomerId);
        _db.Shipments.Add(shipment);
        await _db.SaveChangesAsync();
    }
}
```

### Synchronous (HTTP with Resilience)

When you need request-response between services, wrap the call in **resilience patterns** so a slow dependency can't cascade:

```csharp
builder.Services.AddHttpClient<ICatalogClient, CatalogClient>(client =>
{
    client.BaseAddress = new Uri(
        builder.Configuration["Services:CatalogApi"]!);
})
.AddStandardResilienceHandler();

public class CatalogClient : ICatalogClient
{
    private readonly HttpClient _httpClient;

    public CatalogClient(HttpClient httpClient) => _httpClient = httpClient;

    public async Task<ProductDto?> GetProductAsync(Guid productId)
    {
        var response = await _httpClient.GetAsync($"/api/products/{productId}");

        if (response.StatusCode == HttpStatusCode.NotFound)
            return null;

        response.EnsureSuccessStatusCode();

        return await response.Content.ReadFromJsonAsync<ProductDto>();
    }
}
```

Hardcoding service URLs works for a demo, but not once instances scale or move.
That's the job of **service discovery**.

## API Gateway

An API Gateway sits in front of your microservices, giving clients a single entry point. Use YARP (I walk through a full setup in [**Implementing an API Gateway With YARP**](https://milanjovanovic.tech/blog/implementing-an-api-gateway-for-microservices-with-yarp)):

```bash
dotnet new webapi -n ApiGateway
dotnet add ApiGateway package Yarp.ReverseProxy
```

```csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));

var app = builder.Build();
app.MapReverseProxy();
app.Run();
```

```json
{
  "ReverseProxy": {
    "Routes": {
      "catalog": {
        "ClusterId": "catalog-cluster",
        "Match": { "Path": "/api/products/{**catch-all}" }
      },
      "ordering": {
        "ClusterId": "ordering-cluster",
        "Match": { "Path": "/api/orders/{**catch-all}" }
      }
    },
    "Clusters": {
      "catalog-cluster": {
        "Destinations": {
          "default": { "Address": "http://localhost:5001" }
        }
      },
      "ordering-cluster": {
        "Destinations": {
          "default": { "Address": "http://localhost:5002" }
        }
      }
    }
  }
}
```

## Containerization

Each service gets its own `Dockerfile`:

```dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
EXPOSE 8080

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ["Catalog.Api/Catalog.Api.csproj", "Catalog.Api/"]
RUN dotnet restore "Catalog.Api/Catalog.Api.csproj"
COPY . .
RUN dotnet publish "Catalog.Api/Catalog.Api.csproj" -c Release -o /app/publish

FROM base AS final
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "Catalog.Api.dll"]
```

Orchestrate with Docker Compose:

```yaml
services:
  catalog-api:
    build:
      context: .
      dockerfile: Catalog.Api/Dockerfile
    ports:
      - "5001:8080"
    environment:
      ConnectionStrings__CatalogDb: "Host=postgres;Database=catalog;Username=postgres;Password=postgres"
      ConnectionStrings__RabbitMq: "amqp://guest:guest@rabbitmq:5672"
    depends_on:
      - postgres
      - rabbitmq

  ordering-api:
    build:
      context: .
      dockerfile: Ordering.Api/Dockerfile
    ports:
      - "5002:8080"
    environment:
      ConnectionStrings__OrdersDb: "Host=postgres;Database=orders;Username=postgres;Password=postgres"
      ConnectionStrings__RabbitMq: "amqp://guest:guest@rabbitmq:5672"
    depends_on:
      - postgres
      - rabbitmq

  postgres:
    image: postgres:17
    environment:
      POSTGRES_PASSWORD: postgres
    volumes:
      - pgdata:/var/lib/postgresql/data

  rabbitmq:
    image: rabbitmq:4-management
    ports:
      - "5672:5672"
      - "15672:15672"

  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686"

  prometheus:
    image: prom/prometheus:latest
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml

volumes:
  pgdata:
```

Jaeger receives traces over OTLP gRPC on port 4317 (inside the compose network) and serves its UI on `http://localhost:16686`.
Prometheus works the other way around: it scrapes each service's `/metrics` endpoint (we'll expose it in the next section), so it needs a `prometheus.yml` next to the compose file:

```yaml
scrape_configs:
  - job_name: 'services'
    scrape_interval: 15s
    static_configs:
      - targets: ['catalog-api:8080', 'ordering-api:8080']
```

## Observability

Add OpenTelemetry to each service:

```bash
dotnet add Catalog.Api package OpenTelemetry.Extensions.Hosting
dotnet add Catalog.Api package OpenTelemetry.Instrumentation.AspNetCore
dotnet add Catalog.Api package OpenTelemetry.Instrumentation.Http
dotnet add Catalog.Api package OpenTelemetry.Instrumentation.EntityFrameworkCore
dotnet add Catalog.Api package OpenTelemetry.Exporter.OpenTelemetryProtocol
dotnet add Catalog.Api package OpenTelemetry.Exporter.Prometheus.AspNetCore --prerelease
```

Traces are pushed to Jaeger over OTLP.
Metrics use the pull model instead: the service exposes a `/metrics` endpoint, and Prometheus scrapes it.

```csharp
builder.Services.AddOpenTelemetry()
    .WithTracing(tracing =>
    {
        tracing
            .AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation()
            .AddEntityFrameworkCoreInstrumentation()
            .AddSource("MassTransit")
            .AddOtlpExporter(opts =>
                opts.Endpoint = new Uri("http://jaeger:4317"));
    })
    .WithMetrics(metrics =>
    {
        metrics
            .AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation()
            .AddPrometheusExporter();
    });

var app = builder.Build();

app.MapPrometheusScrapingEndpoint();
```

Distributed traces follow requests across service boundaries automatically.
Open the Jaeger UI at `http://localhost:16686` to see a single trace span the Ordering service, RabbitMQ, and the Shipping service.

## Summary

**Earn the service boundary.**
Start with a modular boundary and extract a service only when independent ownership, deployment, or scaling pays for the distributed-systems cost.
Give the service its own data model, choose communication per interaction, and put external routing behind a deliberate edge boundary.
Containers and OpenTelemetry make operation possible; they do not compensate for a service boundary the organization cannot own independently.

## Frequently asked questions

### Should I start with microservices or a monolith?

Start with a modular monolith unless you already have the team size and operational maturity that microservices demand. A well-structured monolith with clear module boundaries can be split into services later, when you have concrete scaling or organizational reasons.

### Does each microservice need its own database?

Yes. Database-per-service is the defining constraint of the architecture. If two services share a database, they are coupled at the schema level and cannot be deployed or scaled independently, which defeats the purpose.

### How do microservices communicate in .NET?

Preferably asynchronously through a message broker like RabbitMQ using a library such as MassTransit. Synchronous HTTP or gRPC calls are used when the caller needs an immediate answer, and they should be wrapped with resilience handlers (retry, circuit breaker, timeout).

### What infrastructure do you need to run .NET microservices?

At minimum: containers (Docker), a message broker, a database per service, an API gateway such as YARP, centralized logging, and distributed tracing with OpenTelemetry. Kubernetes or a managed container platform comes into play as the number of services grows.

### How many developers do you need for microservices?

There is no hard number, but microservices pay off when multiple teams need to deploy independently. With fewer than roughly 15 developers, the operational overhead usually outweighs the benefits and a modular monolith is the better choice.
