Skip to content
Kumar Chandrachooda
Microservices

Three Ways to Borrow Another Service's Data

Four Pacco services need customer data and no two agree on how to get it - Orders and Parcels keep id-only replicas fed by events, Availability phones home mid-command, and Pricing pulls the full details document to count one number. One upstream, three consistency stances, five representations of the same entity.

By Kumar Chandrachooda 27 Nov 2025 7 min read
One source feeding three different borrowing strategies

Part 9 ended with CustomerCreated leaving the Customers service, translated and ready for consumption. This part follows it to its consumers — because what they each do with it is the estate's best controlled experiment. Four services need to know something about customers: Orders and Parcels need to know one exists, Availability needs to know one is in good standing, Pricing needs to know how loyal one is. Same upstream, same bus, same framework. Three completely different borrowing strategies, each with a different answer to the only question that matters in distributed data: how stale are you willing to be, and what are you willing to pay for freshness?

Database-per-service makes this question unavoidable. Every Pacco service owns its Mongo database; nobody can join across a boundary. So every consumer of customer data must either carry a copy, ask at runtime, or both. Reading how each one chose is a better education in consistency trade-offs than most textbooks, not least because one of the three choices is wearing its price tag openly.

Stance one: the id-only replica

Orders and Parcels take the event-carried route. Each subscribes to CustomerCreated and materialises the thinnest conceivable projection — here is Orders' handler, and Parcels' differs only in exception name:

public async Task HandleAsync(CustomerCreated @event)
{
    if (await _customerRepository.ExistsAsync(@event.CustomerId))
    {
        throw new CustomerAlreadyAddedException(@event.CustomerId);
    }

    await _customerRepository.AddAsync(new Customer(@event.CustomerId));
}

And the entity it stores, in full:

public class Customer
{
    public Guid Id { get; private set; }

    public Customer(Guid id)
    {
        Id = id;
    }
}

That is the whole replica: a set of GUIDs in a local customers collection. It exists to answer exactly one question — does this customer exist? — and it answers it without a network hop, at local read speed, in CreateOrderHandler's first guard (ExistsAsync or throw) and its Parcels twin. No name, no email, no state: nothing that can go stale in any way that matters, because existence is monotonic. As event-carried state transfer goes, this is the disciplined end of the spectrum — carry the minimum, and pick a fact whose staleness window is harmless.

Two wrinkles keep it honest rather than exemplary. First, both handlers throw on duplicate delivery — on an at-least-once bus, a redelivered CustomerCreated poisons the message instead of no-opping, the throwing-receiver convention whose config-versus-code contradiction is part 11's subject. Second, the replica only ever grows richer conceptually — nothing consumes CustomerStateChanged or CustomerBecameVip here, so the projection can never learn more than existence. That is fine today and a trap tomorrow: the first feature that needs customer state in Orders will find a Customer entity already sitting there, one tempting property away from a staleness bug.

Availability, meanwhile, subscribes to the same event and does something almost cheeky — Pacco.Services.Availability.Application/Events/External/Handlers/CustomerCreatedHandler.cs, in its entirety:

public class CustomerCreatedHandler : IEventHandler<CustomerCreated>
{
    // Customer data could be saved into custom DB depending on the business requirements.
    // Given the asynchronous nature of events, this would result in eventual consistency.
    public Task HandleAsync(CustomerCreated @event)
        => Task.CompletedTask;
}

A subscribed, wired, deliberately empty handler whose comment names the trade-off and declines it. It is the estate annotating its own experiment: we could replicate here, and we chose the other stance instead.

Stance two: phone home mid-command

Because what Availability needs is not existence — it is current standing, the one customer fact with a dangerous staleness window. A customer gets locked for fraud at 09:00; a reservation attempt at 09:01 must see the lock. An event-fed replica sees it only after the CustomerStateChanged event has crossed the bus and been applied. So ReserveResourceHandler asks the source directly, synchronously, inside the command:

var customerState = await _customersServiceClient.GetStateAsync(command.CustomerId);
if (customerState is null)
{
    throw new CustomerNotFoundException(command.CustomerId);
}

if (!customerState.IsValid)
{
    throw new InvalidCustomerStateException(command.ResourceId, customerState?.State);
}

The client hits Customers' purpose-built minimal endpoint — GET customers/{id}/state, returning {Id, State} and nothing else — and the DTO reduces it to a verdict: IsValid => State.Equals("valid", StringComparison.InvariantCultureIgnoreCase). Credit where due: Customers exposing a dedicated state endpoint is the producer meeting the consumer halfway, a two-field contract for a two-field question.

The price tag is availability arithmetic. A bus command that could otherwise be processed against local state alone now fails whenever Customers is down, slow, or unreachable — remote procedure invocation spliced into the middle of a messaging workflow, coupling the write path's uptime to another service's. And the contract is stringly-typed at both ends: if State ever arrives null, IsValid throws a NullReferenceException rather than a domain error, and if Customers ever renames a state, the compiler on this side has no opinion. Freshness was bought with fragility, which is the honest shape of the trade — the wrong move would have been pretending the fraud check could tolerate replica lag.

Stance three: pull the whole document, keep one integer

Pricing needs the customer's loyalty standing to compute a discount: how many completed orders, and VIP or not. Its client calls GET customers/{id} — not /state, the full details endpoint, which serves CustomerDetailsDto: email, full name, home address, VIP flag, completed orders. (Served, part of the estate's anonymous-superuser posture, with no authorisation check in the query handler.) Pricing's local CustomerDto binds {Id, IsVip, CompletedOrders}, letting the PII fields fall on the floor at deserialisation, and then:

public static Customer AsEntity(this CustomerDto dto)
    => new Customer(dto.Id, dto.IsVip, dto.CompletedOrders.Count());

The discount ladder consumes IsVip and one integer. So the wire carries a customer's name and home address on every single pricing calculationAssignVehicleToOrder calls Pricing, Pricing calls Customers, per assignment — so that the caller can count the length of a list it otherwise ignores. Everything Pricing needs would fit in the /state endpoint's envelope plus two fields; what it requests instead is the whole document. This is the over-fetch quadrant: synchronous freshness and maximum payload, paying stance two's coupling cost and adding a data-minimisation smell on top. The missing move is a content filter — either a leaner endpoint (GET customers/{id}/loyalty, say) or event-carried counters. And the borrowed data lands in an entity with its own part-7-adjacent scar: Pricing's Customer constructor accepts id and never assigns it, so the one field that identifies whose loyalty this is stays Guid.Empty forever — harmless today, a landmine for the first feature that keys anything on it.

Five representations, one entity

Line the experiment up end to end and the same business entity exists in five shapes across the estate:

Where Shape Refreshed Answers
Customers (owner) Full aggregate — email, name, address, state, VIP, completed orders Source of truth Everything
Orders {Id} On CustomerCreated, then never Does it exist?
Parcels {Id} On CustomerCreated, then never Does it exist?
Availability {State} DTO Every command, over HTTP Is it in good standing?
Pricing {IsVip, CompletedOrdersNumber} (id dropped) Every calculation, over HTTP How loyal is it?

No two consumers hold the same shape, and — this is the part worth stealing — none of them holds more than it queries. The dysfunction in this estate lives elsewhere: in the duplicate-throwing receivers, the PII-bearing over-fetch, the unassigned id. The shapes themselves are each defensible answers to different questions, and the table doubles as a decision menu I now reach for:

  • Borrow by event when the fact is stable and the question is cheap — existence, immutable attributes. Carry the minimum; make the receiver idempotent.
  • Borrow by call when staleness is a business risk — standing, entitlement, anything fraud-adjacent. Ask a purpose-built endpoint for exactly the fields the question needs, and treat the callee's downtime as your own.
  • Never borrow more than the question. Every surplus field is either a liability in transit (PII you did not need) or a temptation at rest (a replica field somebody will read after it went stale).

One thread ties all three stances together, and it is the cliff the series has been walking toward. The replicas in stance one are only as trustworthy as the events that feed them — and every one of those events is written by Convey's outbox, on which every service in this shard sets the same startling flag: disableTransactions: true. The entity write and the outbox write are separate, non-atomic operations; the guarantee the pattern exists to provide is switched off in configuration, estate-wide, while the inbox that would absorb duplicates sits beside handlers that throw on them. Guaranteed delivery in name, at-least-once-if-lucky in fact. Next: the outbox with transactions turned off.