Skip to main content

    Advanced Track

    Microservices in .NET

    By the end of this lesson you'll be able to split a system into independent services along bounded contexts, choose synchronous (gRPC/HTTP) or asynchronous (message bus) communication for each link, make calls fault-tolerant with retries and circuit breakers, and reason about data ownership, observability, and Docker packaging — the real-world skills behind distributed .NET systems.

    What You'll Learn

    • Draw service boundaries around bounded contexts (one service, one job)
    • Choose synchronous gRPC/HTTP vs asynchronous message-bus communication
    • Make calls resilient with retries, circuit breakers, and timeouts (Polly)
    • Give each service its own database — and why a shared one is an anti-pattern
    • Add observability: structured logs, traces, and health checks
    • Package and run services with Docker for consistent deployment

    💡 Real-World Analogy

    Think of a food court versus one giant restaurant kitchen. The giant kitchen (a monolith) does everything under one roof — if the oven breaks, the whole place stops, and you can't add more pizza ovens without rebuilding the kitchen. A food court is a row of independent stalls: the noodle stall, the burger stall, the smoothie bar. Each has its own kitchen, its own till, its own staff — that's a microservice. If the smoothie blender dies, you still get your noodles. Busy stalls add staff without touching the others. They cooperate by passing orders (messages and API calls), never by reaching into each other's fridges (each service owns its own data). That independence is the whole point — and the source of the extra complexity you'll learn to manage here.

    📊 Monolith vs Microservices — the Trade-offs

    AspectMonolithMicroservices
    DeploymentOne unit — simple to shipMany units — deploy independently
    ScalingScale the whole app togetherScale only the busy service
    DataOne shared databaseA database per service
    FailureOne crash can stop everythingFailures isolated (if resilient)
    CallsIn-process method calls (fast, reliable)Network calls (slower, can fail)
    TeamsCoordinate on one codebaseEach team owns a service
    ComplexityLow to start; grows tangledHigh up front (network, ops, tracing)

    Microservices are not "better" — they trade simplicity for independence. Reach for them when you have real scaling pressure or multiple teams. Otherwise a well-structured monolith is often the right call.

    1. Service Boundaries — One Service per Bounded Context

    The first and hardest decision is where to cut. Draw each boundary around a bounded context — a self-contained slice of the business with its own language and rules, like Orders, Inventory, or Shipping. A good boundary means a service can do its job using mostly its own data and change without forcing changes elsewhere. Crucially, a service owns its data: others see a contract (a message or an API response), never the raw tables. Read this worked example, run it, and notice how the services cooperate by passing a small record rather than sharing storage.

    Worked example: three services, one contract

    Run it and see how Orders hands the others a summary record, not its database.

    Try it Yourself »
    C#
    using System;
    
    // Each microservice owns ONE bounded context — a slice of the business
    // with its OWN data and its OWN rules. Services never reach into each
    // other's tables; they talk only through contracts (messages or APIs).
    class Program
    {
        static void Main()
        {
            // Three independent services, each modelled here as a tiny class.
            var orders = new OrderService();
            var inventory = new InventoryService();
            var shipping = new ShippingService();
    
            // The
    ...

    2. Communication — Synchronous vs Asynchronous

    Services talk in two fundamentally different ways. Synchronous calls (gRPC or HTTP) are a phone call: you ask, you wait, you get an answer right now — great when you genuinely need the reply to continue, but the caller is blocked and coupled to the callee being up. Asynchronous messaging (a message bus) is dropping a letter in the post: you publish an event and move on, and interested services pick it up on their own time — slower to "complete" but far more decoupled and resilient. A good rule: query synchronously, react asynchronously.

    Synchronous with gRPC. gRPC sends compact binary over HTTP/2 and generates strongly-typed clients from a .proto contract, so calling another service feels like calling a local method — and it's several times faster than JSON-over-HTTP. Here's the real shape of a gRPC server and client.

    Worked example: a gRPC service & client

    Read the .proto contract, the server override, and the typed client call.

    Try it Yourself »
    C#
    // ══════════════════════════════════════════════
    // gRPC — fast, strongly-typed SYNC calls between services
    // (real .NET code; needs Grpc.AspNetCore + a .proto file to run)
    // ══════════════════════════════════════════════
    
    // product.proto — the contract both client and server are generated from:
    // syntax = "proto3";
    // service ProductService {
    //     rpc GetProduct (ProductRequest) returns (ProductReply);
    // }
    // message ProductRequest { int32 id = 1; }
    // message ProductReply  { int32 id =
    ...

    Asynchronous with a message bus. Before the real broker, model the essential idea in plain C#: a publisher builds an immutable message record (the DTO — Data Transfer Object — both sides agree on) and a consumer handles it. Finish the two blanks below.

    🎯 Your turn: a message DTO from publisher to consumer

    Pass the OrderCreated record to the handler, then check the output.

    Try it Yourself »
    C#
    using System;
    
    // 🎯 YOUR TURN — model async messaging with a plain DTO (no broker needed).
    // A 'publisher' builds a message record; a 'consumer' handles it. In real
    // systems a message bus carries this record between two separate services.
    
    // A message DTO is an IMMUTABLE record — the contract both sides agree on.
    record OrderCreated(int OrderId, string CustomerEmail, decimal Total);
    
    class Program
    {
        static void Main()
        {
            // 1) PUBLISH: build the message the Order service woul
    ...

    In a real system, MassTransit over RabbitMQ carries that record between two separately running services. The publisher fires an event and forgets it; any number of consumers subscribe without the publisher ever knowing they exist. That zero coupling is the superpower of async messaging.

    Worked example: MassTransit publish & consume

    See how the publisher fires an event and a consumer reacts independently.

    Try it Yourself »
    C#
    // ══════════════════════════════════════════════
    // MassTransit + RabbitMQ — ASYNC, decoupled messaging
    // (real .NET code; needs MassTransit + a running broker to execute)
    // ══════════════════════════════════════════════
    
    using MassTransit;
    
    // Shared contract — lives in a package both services reference.
    public record OrderCreated(Guid OrderId, string Email, decimal Total);
    
    // PUBLISHER — the Order service fires an event and forgets about it.
    // It does NOT know or care who is listening. Ze
    ...

    3. Resilience — Expect Every Call to Fail

    Over a network, calls will fail — timeouts, blips, a service restarting. A resilient system absorbs that instead of crashing. A retry tries a transient failure again, ideally with growing delays (exponential backoff) so you don't hammer a struggling service. A circuit breaker watches the failure rate and, once it's too high, "opens" to stop calls for a while — letting a sick service recover instead of drowning it. A timeout caps how long you'll wait. First, build the core retry loop yourself; finish the three blanks.

    🎯 Your turn: a retry loop that gives up after N tries

    Fill in the blanks so it retries up to maxRetries, then degrades gracefully.

    Try it Yourself »
    C#
    using System;
    
    // 🎯 YOUR TURN — finish the retry loop, then run it.
    // A remote service is flaky: it fails the first 2 attempts, then succeeds.
    // Real resilience (Polly) does exactly this — retry up to N times, then give up.
    class Program
    {
        static int _attempts = 0;
    
        static void Main()
        {
            int maxRetries = 3;        // try at most 3 times before giving up
            bool success = false;
    
            // 1) Loop from attempt 1 up to and including maxRetries.
            for (int attempt 
    ...

    You'd never hand-write that in production — Polly (built into Microsoft.Extensions.Http.Resilience) gives you retry, circuit breaker, and timeout as a configured pipeline on your HttpClient. Notice how the consuming service catches the final failure and returns a fallback — graceful degradation — rather than letting one dead dependency take down the whole request.

    Worked example: Polly retry + circuit breaker + timeout

    Read the resilience pipeline and the graceful-degradation fallback.

    Try it Yourself »
    C#
    // ══════════════════════════════════════════════
    // Polly via Microsoft.Extensions.Http.Resilience
    // retry + circuit breaker + timeout on an HttpClient
    // (real .NET code; needs the resilience NuGet package to run)
    // ══════════════════════════════════════════════
    
    using Polly;
    
    // Program.cs — every call through this client is now resilient.
    builder.Services.AddHttpClient("products", c =>
        c.BaseAddress = new Uri("https://product-service:5001"))
      .AddResilienceHandler("pipeline", b =>
    {
     
    ...

    🔎 Deep Dive: a Database per Service

    The single firmest rule in microservices: each service owns its data, and no other service touches it directly. The Orders service has the orders database; if Shipping needs order data, it asks Orders via an API or learns it from a published event — it never runs a query against the orders tables.

    Why so strict? A shared database silently couples every service to one schema. Change a column and you risk breaking three teams' deployments at once — you've built a distributed monolith with all the cost of microservices and none of the independence.

    The trade-off is that data is now spread out. When you need a consistent change across services, you use events and accept eventual consistency: Orders publishes OrderCreated, Inventory reacts a moment later. The system becomes correct soon, not instantly — and for most business workflows that's perfectly fine.

    orders-service     ──▶  orders-db      (only orders-service connects)
    inventory-service  ──▶  inventory-db   (only inventory-service connects)
    shipping-service   ──▶  shipping-db    (only shipping-service connects)
    
    // They share DATA via events/APIs — never a shared connection string.

    4. Observability — Seeing Inside a Distributed System

    In a monolith you read one log file. With a request hopping across five services, you need observability: the three pillars are logs (structured, searchable records of events), metrics (numbers over time — request rate, error rate, latency), and traces (one request's whole journey across services, tied together by a shared correlation id). .NET ships first-class support via ILogger for structured logging, OpenTelemetry for traces and metrics, and health checks so an orchestrator knows whether a service is alive.

    // Structured logging — log VALUES, not pre-built strings, so they're queryable.
    _logger.LogInformation("Order {OrderId} created for {Email}", id, email);
    
    // OpenTelemetry — automatic distributed traces across HTTP and gRPC calls.
    builder.Services.AddOpenTelemetry()
        .WithTracing(t => t.AddAspNetCoreInstrumentation().AddHttpClientInstrumentation());
    
    // Health checks — the orchestrator polls /health to decide if you're ready.
    builder.Services.AddHealthChecks();
    app.MapHealthChecks("/health");

    5. Packaging with Docker

    Each service ships as a container — your code plus exactly the runtime it needs, frozen into one image that runs identically on your laptop, in CI, and in production. That's what makes "deploy services independently" practical. A Dockerfile describes how to build the image; docker compose (or Kubernetes in production) runs the whole fleet together with its databases and message broker.

    # Dockerfile — multi-stage: build with the SDK, run on the slim runtime.
    FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
    WORKDIR /src
    COPY . .
    RUN dotnet publish OrderService -c Release -o /app
    
    FROM mcr.microsoft.com/dotnet/aspnet:8.0       # smaller runtime-only image
    WORKDIR /app
    COPY --from=build /app .
    ENTRYPOINT ["dotnet", "OrderService.dll"]
    
    # docker-compose.yml — run the services + their infrastructure together.
    # services:
    #   orders:    { build: ./OrderService,    ports: ["5001:8080"] }
    #   inventory: { build: ./InventoryService, ports: ["5002:8080"] }
    #   rabbitmq:  { image: rabbitmq:3-management }

    Sync or Async? A Quick Decision Guide

    Reach for synchronous (gRPC/HTTP) when the caller genuinely needs the answer right now to continue — fetching a product's price to show on a page, validating a token. Keep these calls few and fast.

    Reach for asynchronous (message bus) when something happened and others should react, but the caller doesn't need to wait — order placed, payment received, user signed up. This is the default for cross-service workflows because it decouples teams and survives a consumer being temporarily down.

    The classic mistake is wiring everything synchronously: one slow service then stalls a whole chain of callers, and a single outage cascades. Events break that chain.

    Pro Tips

    • 💡 Start with a modular monolith. Build clear bounded contexts in one deployable app first; extract a service only when a real scaling or team-ownership need appears. Premature microservices add cost with no benefit.
    • 💡 Prefer events over chains of sync calls. If service A calls B calls C synchronously, one slow link stalls them all. Publish an event and let consumers react independently.
    • 💡 Make every network call resilient. Wrap HttpClient with a Polly retry + circuit breaker + timeout pipeline by default — assume calls fail.
    • 💡 One database per service, always. Sharing a database recreates a monolith's coupling with a network's fragility — the worst of both.
    • 💡 Propagate a correlation id on every call so a single request can be traced end-to-end across all services in your logs.
    • 💡 Design for idempotency. A message bus may deliver the same event twice — handlers should produce the same result if they run again.

    Common Errors (and the fix)

    • The distributed monolith: services that must all deploy together because they're chained by tight synchronous calls. You've taken on the network's pain without the independence. Fix: decouple with events and define stable, versioned contracts so services can change separately.
    • Shared database between services: two services reading and writing the same tables couples them to one schema forever — change a column and unrelated services break. Fix: give each service its own database; share data via APIs and published events only.
    • No resilience — cascading failure: one slow or down service makes every caller hang, and the failure spreads up the chain until the system falls over. Fix: add timeouts, retries with backoff, and a circuit breaker (Polly), plus a graceful fallback.
    • Chatty synchronous calls: rendering one page fires twenty cross-service requests, so latency stacks up and reliability craters. Fix: batch requests, cache, or push the data ahead of time via events so the page needs no live calls.
    • Ignoring duplicate messages: assuming an event arrives exactly once. Brokers guarantee at-least-once, so the same OrderCreated can arrive twice. Fix: make consumers idempotent (e.g. ignore an order id you've already processed).

    📋 Quick Reference

    ConcernTool / PatternUse when
    Fast sync callgRPCYou need the reply now, low latency
    Public sync callHTTP / RESTBrowsers or external clients
    Async eventMassTransit + RabbitMQSomething happened; others react
    ResiliencePolly pipelineRetry, circuit breaker, timeout
    Data ownershipDB per serviceAlways — never share a database
    TracingOpenTelemetryFollow a request across services
    PackagingDocker + composeShip and run services consistently

    Frequently Asked Questions

    Q: When should I actually use microservices?

    When you have a clear pressure they solve — independent scaling of one busy area, or multiple teams that need to deploy without blocking each other. If you don't have that yet, a modular monolith is simpler and faster to build. Microservices are an organisational and scaling tool, not a default.

    Q: gRPC or REST between services?

    Use gRPC for internal service-to-service calls where speed and a strongly-typed contract matter. Use REST/HTTP at the edge, for browsers and external clients that expect plain JSON. Many systems use both: gRPC inside, REST at the gateway.

    Q: What is a circuit breaker and why not just retry forever?

    Retrying a service that's genuinely down just adds load and delays the inevitable failure. A circuit breaker watches the failure rate and, once it's too high, stops calls for a cool-down window so the struggling service can recover — then cautiously tests again. It turns a slow cascade into a fast, contained failure.

    Q: Can two services share one database to save effort?

    No — that's the most common way to ruin a microservices design. A shared database couples both services to one schema and one deployment, recreating a monolith's rigidity with a network's fragility. Each service must own its data; share it through events and APIs.

    Q: What does "eventual consistency" mean in practice?

    Because each service has its own data, a change can't be instant everywhere. Orders publishes an event, Inventory reacts a moment later — so for a brief window the two are out of step, then they agree. For most business workflows that small delay is acceptable and far simpler than distributed transactions.

    Mini-Challenge: a Tiny In-Memory Message Bus

    No blanks this time — just a brief and an outline. Build a minimal pub/sub MessageBus: one "service" subscribes a handler, another publishes a PaymentReceived event, and the bus delivers it. This is the real shape of a message broker, stripped to its essence. Run it and check your output against the expected line.

    🎯 Mini-Challenge: publish & handle on a message bus

    Implement Subscribe and Publish; the receipt line should print for order 5.

    Try it Yourself »
    C#
    using System;
    using System.Collections.Generic;
    
    // 🎯 MINI-CHALLENGE: a tiny in-memory message bus
    // Model pub/sub with no broker — one service publishes, another handles.
    //
    // 1. Define a record PaymentReceived(int OrderId, decimal Amount).
    // 2. In MessageBus:
    //    - Keep a List of handlers:  List<Action<PaymentReceived>>
    //    - Subscribe(handler): add the handler to the list.
    //    - Publish(message): call EVERY subscribed handler with the message.
    // 3. In Main:
    //    - Create a bus.
    //
    ...

    🎉 Lesson Complete

    • ✅ Draw service boundaries around bounded contexts — one service, one job, its own data
    • Synchronous (gRPC/HTTP) for "I need the answer now"; asynchronous (message bus) for "this happened, react"
    • ✅ Make every network call resilient — retry with backoff, circuit breaker, timeout, graceful fallback
    • A database per service; share data via events and APIs, embracing eventual consistency
    • Observability — structured logs, metrics, distributed traces, and health checks
    • Docker packages each service into a portable image you can deploy independently
    • ✅ Avoid the distributed monolith, the shared database, and chatty sync chains
    • Next lesson: Background Jobs — running reliable scheduled and queued work

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service

    Install LearnCodingFast

    Learn faster with the app on your home screen.