Microservices & System Design with .NET
In short: Small, independently deployable services, each owning one business capability and its own data, talking over HTTP/gRPC or messages.
- Advantages: independent deployment and scaling, team autonomy, fault isolation, technology freedom per service.
- Disadvantages: distributed-system complexity (network failures, latency), no cross-service transactions (eventual consistency, sagas), harder testing and debugging, much more DevOps and monitoring work, data duplication.
In short: Usually start with a modular monolith: one deployable with strict module boundaries and separate schemas.
- It is faster to build and easier to operate, and the boundaries let you extract services later.
- Choose microservices when there is a concrete need — independent scaling, separate teams with separate release cycles, very different technology or reliability needs — and the organisation can afford the DevOps cost.
In short: Synchronously with REST over HTTP (public/browser APIs) or gRPC (fast internal calls, contracts in .proto), usually through an API gateway;
- asynchronously with messages through a broker (RabbitMQ, Azure Service Bus, Kafka) using queues for commands and topics for events.
- Prefer async for anything that doesn't need an immediate reply — it decouples availability and absorbs load spikes.
In short: A single entry point in front of the services.
- It routes requests, terminates TLS, validates tokens, applies rate limiting and CORS, can aggregate several calls into one response and hides the internal topology from clients. .NET: YARP or Ocelot;
- Azure: API Management or Application Gateway.
- A Backend-for-Frontend is a gateway tailored to one client type.
In short: Each service owns its tables; nobody else reads or writes them directly.
- Cross-service data is obtained by calling the owner's API, by API composition (gateway/BFF calls several services and merges), or by keeping a local read-only copy updated via events (CQRS read model).
- Reporting usually goes to a separate data warehouse fed by events or CDC.
In short: A saga is a sequence of local transactions across services, each with a compensating action to undo it if a later step fails (charge → refund).
- Choreography: each service listens for events and publishes the next one — simple, decentralised, but the overall flow is hard to see.
- Orchestration: a central coordinator (state machine) sends commands and tracks state — explicit and easier to monitor and change, but one more component.
In short: The dual-write problem: saving to the database and publishing a message are two systems, so a crash between them leaves them inconsistent.
- With an outbox, the business data and the message are written in one local transaction (message into an Outbox table);
- a separate relay publishes outbox rows to the broker and marks them sent.
- Delivery becomes at-least-once, so consumers must be idempotent (often an Inbox table of processed ids).
In short: Timeouts on every call;
- retries with exponential backoff and jitter for transient, idempotent operations;
- circuit breaker to fail fast while a dependency is down;
- bulkheads to limit concurrency;
- fallbacks/cached data;
- async messaging so work is queued rather than lost;
- health checks and auto-restart.
- In .NET:
AddStandardResilienceHandler()/ Polly.
In short: An operation is idempotent if doing it twice has the same effect as once.
- Messages are delivered at least once and clients retry on timeouts, so duplicates are normal.
- Techniques: idempotency keys on POST (store key + result, return the same result on repeat), natural keys/unique constraints, processed-message tables, "set" operations instead of "increment".
In short: gRPC for internal service-to-service calls where performance and strict contracts matter: binary Protobuf, HTTP/2 multiplexing, code-generated clients, streaming.
- REST for public APIs, browser clients, easy debugging and broad tooling. gRPC needs gRPC-Web or a gateway for browsers.
In short: Use OpenTelemetry: add ASP.NET Core, HttpClient and SqlClient instrumentation and an exporter (OTLP to Jaeger, Aspire dashboard, Application Insights). .NET's Activity API propagates the W3C traceparent header automatically, so all spans of one request share a trace id.
- Add the trace id to logs to jump from a log line to the full trace.
In short: Accept eventual consistency and design for it: keep each aggregate's invariants inside one service and one local transaction;
- use sagas with compensations for multi-service workflows;
- outbox + idempotent consumers for reliable events;
- reservation patterns (reserve stock, confirm later, expire if unpaid);
- show pending states in the UI.
- Two-phase commit (MSDTC) is avoided — it blocks and doesn't work across most cloud services and brokers.
In short: Separate models for writes (commands that enforce business rules) and reads (queries/views optimised for screens).
- Worth it when reads vastly outnumber writes, read shapes differ a lot from the write model, or reads and writes need to scale separately.
- Overkill for simple CRUD.
- It doesn't require event sourcing or separate databases; a common form is EF Core for commands and Dapper over views for queries.
In short: Prefer additive, backward-compatible changes (new optional fields; consumers ignore unknown fields).
- For breaking changes: URL or header versioning (Asp.Versioning), run old and new side by side, deprecate with a timeline.
- For events: include a version, never change meaning of existing fields, publish new event types for big changes, use a schema registry for Kafka/Avro.
- Consumer-driven contract tests (Pact) catch breaks in CI.
In short: Make it stateless (no in-memory session or local files;
- use Redis, cookies, Blob storage), run multiple instances behind a load balancer, use a distributed cache and a shared Data Protection key ring, keep DB connections pooled and queries efficient, and autoscale on CPU/RPS or queue length (Kubernetes HPA/KEDA, Container Apps rules).
- Background jobs need a lock or a queue so they don't run on every instance.
In short: Requirements: create short link, redirect fast, optional expiry and analytics; e.g. 100M links, read-heavy (100:1).
- API:
POST /links→{ code },GET /{code}→ 301/302. - Key generation: base62 of a unique id (DB sequence or Snowflake-style id) or a random 7-char code with uniqueness check.
- Store code → URL in a key-value store or SQL with a unique index; cache hot codes in Redis; CDN/edge for redirects.
- Analytics: publish click events to a queue/stream and aggregate asynchronously so redirects stay fast.
- Discuss collisions, abuse (rate limits, malware checks) and custom aliases.
In short: Cascading failure from synchronous chains.
- Add timeouts and a circuit breaker around the fraud call, a bulkhead so slow calls can't use every connection, and a fallback (accept low-value orders, flag for review).
- Better: make the flow asynchronous — accept the order as Pending, send a message for payment/fraud processing, update status by events, and notify the user.
- Scale the fraud service, and add tracing to prove where the time goes.
In short: Make the payment endpoint idempotent: the client sends an Idempotency-Key header (GUID per checkout attempt);
- the server stores the key with the result in the same transaction as the charge;
- a repeat request with the same key returns the stored result instead of charging again.
- Also pass an idempotency key to the payment provider (Stripe and others support this) and use a unique constraint on (OrderId, PaymentAttempt).
In short: Don't big-bang rewrite.
- Map bounded contexts and coupling first; make the monolith modular (clear modules, separate schemas, no cross-module table access).
- Use the strangler fig pattern: put a gateway (YARP) in front, extract one well-bounded, high-value module at a time (often notifications, catalog or reporting), give it its own data (sync via events during migration), route its traffic to the new service, then remove the old code.
- Invest early in CI/CD, containers and observability.
In short: Configure limited retries with backoff, then move the message to the dead-letter queue so processing continues.
- Alert on DLQ depth, log the failure with the message id, fix the bug or the data, then replay from the DLQ.
- Make the consumer idempotent so replays are safe, and validate message schemas.