Skip to main content
ANVISoftware Solutions
Lesson 15 of 19Professional22 min

Microservices

By the end of this lesson

Weigh independent deployment against distributed complexity.

A microservice is a separately deployable application that owns its own data and is reached over a network. Orders is one service with one database. Invoicing is another, with another. Neither reads the other's tables; they talk through an interface published over HTTP or a broker.

The word small in the usual definition is the least useful part of it. What actually changes is deployment: each service is released on its own, so a change to invoicing requires no ordering release and no coordination meeting. Everything gained and everything lost follows from that one property.

The same intent, in one process and then across two
C#
// One process. The compiler checks the call, and it cannot half-happen.
public async Task ApproveAsync(int orderId, CancellationToken token)
{
    Order order = await orders.FindAsync(orderId, token)
        ?? throw new OrderNotFoundException(orderId);

    order.Approve(clock.GetUtcNow().UtcDateTime);
    invoicing.Raise(order);                 // in-process call

    await db.SaveChangesAsync(token);       // one transaction covers both
}

// Two processes. Same intent, plus everything the network adds.
public async Task<ApprovalOutcome> ApproveAsync(int orderId, CancellationToken token)
{
    Order order = await orders.FindAsync(orderId, token)
        ?? throw new OrderNotFoundException(orderId);

    order.Approve(clock.GetUtcNow().UtcDateTime);
    await db.SaveChangesAsync(token);       // committed: the order is approved

    var request = new RaiseInvoice(order.Id, order.Number, order.Total, "GBP");

    try
    {
        HttpResponseMessage response = await invoicingApi.PostAsJsonAsync(
            "/invoices", request, token);

        if (response.StatusCode == HttpStatusCode.Conflict)
            return ApprovalOutcome.ApprovedAlreadyInvoiced;

        response.EnsureSuccessStatusCode();
        return ApprovalOutcome.ApprovedAndInvoiced;
    }
    catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
    {
        log.LogError(ex, "Order {OrderId} approved; invoicing did not answer.", order.Id);
        await outbox.ScheduleAsync(request, token);
        return ApprovalOutcome.ApprovedInvoicePending;
    }
}
  • The first version is checked at build time. Rename a parameter in invoicing and this file stops compiling, which is a defect found in seconds rather than in production.
  • The second version cannot share a transaction, so a state exists that the first version could not reach: approved, not invoiced. That state needs a name, a screen that shows it, and someone who chases it. Notice the return type had to grow to express it.
  • The 409 branch is there because a retry can land on a request invoicing already processed. That obliges the other team to make their endpoint idempotent, which is a contract you now have to agree and test with them.
  • The catch covers three situations that are indistinguishable from here: the request never arrived, it was processed and the reply was lost, or invoicing is merely slow. You cannot tell which, so the honest response is to schedule a retry and accept possible duplicate work.
  • Type safety across the boundary is gone. The contract is a JSON shape described in a document, so a field rename on their side becomes a run-time failure on yours, at the worst moment.
  • This is one call. A flow crossing four services carries this weight four times, and the combinations of partial failure multiply rather than add.

A modular monolith against microservices. Both have real module boundaries; the difference is the process line:

 Modular monolithMicroservices
Calls between modulesMethod calls, checked by the compilerNetwork calls, checked at run time
A change across two modulesOne commit, one releaseTwo releases, in a compatible order
ConsistencyOne transaction where you need itSagas and compensating actions
Deploying one partReleases everythingReleases that part only
Scaling one partScale the whole applicationScale that service
A crashTakes the application downTakes one capability down, if you designed for it
Debugging a flowOne stack trace, one debuggerCorrelated logs across processes, and a tracing tool
Operational surfaceOne pipeline, one set of dashboardsOne of each per service

What each additional service costs, in work somebody has to do:

A pipeline and a release path
Build, test, package, deploy, roll back, plus secret and configuration management. Multiplied by the number of services, and each one drifts unless you invest in keeping them alike.
Monitoring and someone to call
Health checks, dashboards, alert thresholds and an owner. A service nobody watches fails silently until a customer notices.
Distributed tracing
Without a correlation identifier flowing through every call and message, debugging a flow across four services means aligning timestamps by hand. This is not optional at three services; it is how you keep the ability to investigate.
Contract coordination
Schemas become inter-team agreements with versions and deprecation windows. Contract tests are what stop a compatible-looking change breaking a consumer you forgot about.
Service-to-service authentication
Requests between services need identity of their own. Trusting the network because it is internal means any compromised service can act as any other.
Consistency work
No shared transaction, so multi-step flows need sagas with compensating actions. Cancelling an invoice is not a rollback; it is a second business operation someone has to specify.
A local development story
Running nine services on a laptop needs containers, seed data and patience. When it becomes impractical, developers stop running the flow they are changing, and defects move to the integration environment.
Data that used to be a join
An order list showing customer names cannot join across services. It becomes a call per request, a batched call, or a copy of customer data that is now slightly stale and needs an owner.

Scale is the reason given most often and it is the weakest one. A single application can run as twenty identical copies behind a load balancer, sharing one database, and handle very large volumes. You get horizontal scale without splitting anything. What you cannot do that way is size parts differently: if the PDF renderer needs eight gigabytes of memory and the API needs half of one, you pay for the larger figure everywhere. That is a genuine argument, and it is much narrower than "we need to scale".

Two arguments do hold. The first is independent deployment: separate teams with different release rhythms, where one team's half-finished work must not delay another team's release, and where a risky change should be able to go out without re-testing everything else. The second is team autonomy at a size where a single codebase creates real coordination cost — many teams, one repository, one release train, and a queue to get anything out.

So team size and the need for independent deployment decide this, not throughput. Three teams that block each other every fortnight have the problem microservices solve. One team of six deploying twice a week does not, and splitting their application adds the coordination cost of many teams without giving them any of the independence, because every change still touches several services and they still all ship together.

For a small team the better answer is almost always a modular monolith: real module boundaries, dependency rules the build enforces, separate schemas per module if you like, and one deployable unit. You keep compile-time checking, one transaction, one stack trace and one pipeline. The boundaries you draw there are the boundaries you would need anyway, and drawing them is the work that makes a later split possible.

If independent deployment is genuinely needed, extract one service and stop to reassess. This order is deliberate:

  1. Draw the boundary inside the monolith first

    Make it a module with an explicit public surface and a dependency rule the build enforces. If you cannot agree where the line goes while everything is in one solution, moving the confusion onto a network will not clarify it.

  2. Cut the shared data access across that line

    No other module reads the module's tables. This is the hard step and the one most attempts stall on, because it is where you discover which reports, exports and overnight jobs quietly depend on those tables.

  3. Route every call through one interface

    While still in process, so the compiler proves you found all the call sites. Then measure how chatty that interface is: forty calls per request predicts a service you will regret.

  4. Choose the candidate on coupling, not on pain

    The least entangled module with the clearest data ownership goes first. The most painful part of the codebase is usually the most entangled, which makes it the worst first extraction.

  5. Move it out behind the same interface

    The implementation becomes an HTTP or messaging adapter. Callers keep the interface they already had, so the change is one registration plus the failure handling the network now demands.

  6. Give it everything a service needs before it takes traffic

    Pipeline, dashboards, alerts, an owner, service-to-service authentication, correlation identifiers and contract tests with its consumers. A service without these is an outage waiting for a quiet weekend.

  7. Run it for a month, then decide about the next one

    You now have real numbers on latency, failure handling and operational load. Teams that extract six services in one programme find out all six times at once.

Summary

  • A microservice is separately deployable and owns its data; independent deployment is the whole point
  • Method calls become network calls, and one transaction becomes a saga with compensating actions
  • Team size and deployment independence decide the split, not throughput
  • Each service brings a pipeline, dashboards, alerts, contracts and an owner
  • A small team is almost always better served by a modular monolith with enforced boundaries

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Think about it

Count the states you created

In the monolith, approving an order and raising its invoice happen in one transaction. As two services, list the states the system can now be in after a user clicks approve.

For each one, say who notices, how, and what puts it right.

Show solution

Approved and invoiced. The intended outcome, and the only one the monolith could produce.

Approved, invoicing never received the request. Detected by the retry mechanism if you built one; by finance at month-end if you did not. Fixed by a retry, which is only safe because their endpoint is idempotent.

Approved, invoicing processed it, the reply was lost. Your service believes the invoice is missing and retries. Without idempotency on their side this produces two invoices, and a customer receives both.

Approved, invoicing is slow and your timeout fired. Identical to the previous case from your side, and it is why timeouts and retries have to be designed together rather than tuned separately.

Not approved, because your own commit failed. Clean, and the only failure the single-process version could produce.

Approved, invoicing returned a business refusal — the customer is over their credit limit. This is not a technical failure and no retry helps. Somebody has to decide whether the approval stands, and that decision is a business rule that did not exist before the split.

Six states where there was one, and four of them need an owner, a screen and a policy. That inventory is the real cost of the split, and doing it before the work is what lets you estimate honestly.

Challenge

Write the bill before the proposal

Someone proposes extracting invoicing into its own service. Write the full cost, not the design: every new pipeline, dashboard, alert, contract, test, failure state, screen and piece of documentation the split creates, plus who maintains each.

Then write the benefit in the same units. Decide, and record the reasoning.

Show solution

A realistic bill runs long: a repository and pipeline, deployment and rollback, configuration and secrets, health checks, dashboards, alerts and an owner, service-to-service authentication, correlation identifiers end to end, a versioned contract, contract tests on both sides, retry and idempotency handling, a pending-invoice state with a screen and a chase process, local development setup, and a runbook.

The benefit column is usually much shorter, and its content decides the question. "Invoicing changes weekly and cannot wait for the ordering release train" is a benefit worth all of the above. "Invoicing is the slow part" is a profiling task. "The monolith is hard to work in" is an argument for module boundaries, which you can have without the network.

Writing both columns tends to produce a third option: extract invoicing as an enforced module now, with its own schema and no cross-boundary queries, and revisit the process split in six months. Most of the benefit, almost none of the bill, and the work is not wasted if you do split later.

If you do decide to split, the bill you wrote is your plan. Teams that skip this step build the service in a fortnight and then spend two quarters discovering the list one item at a time, in production.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

Which argument best justifies splitting an application into separate services?
Four services are called in a synchronous chain, each available 99.9% of the time. What is the availability of the flow?

Saved in this browser only.