ARCHITECTURE GUIDE · UPDATED 11 AUG 2026

Microservices: architecture that earns its complexity

Millions of daily user interactions across vending fleets and EV charging networks taught me one thing: microservices are not a goal, they are a cost you pay for independent scaling and deployment — and the bill arrives in operations.

When the split is worth it

My vending platform needed telemetry ingestion scaled independently from order processing; my EV platform needs OCPI roaming isolated from charger management so a partner outage never touches charging sessions. Those are real reasons to split. “The team read about microservices” is not. I default to a modular monolith and extract services only when scaling, isolation, or deployment cadence demand it.

Boundaries that survived production

  • Charger management (OCPP connections, device state) — stateful, connection-heavy, scales with fleet size.
  • Roaming (OCPI modules, partner credentials) — isolated so partner failures are contained.
  • Sessions & billing (session lifecycle, CDRs, tariffs) — the money path, most conservative deploy cadence.
  • Telemetry (MQTT ingestion, time-series storage) — highest throughput, most aggressive scaling.
  • Each owns its data. Shared databases between services are a distributed monolith with extra steps.

Communication rules

  • Synchronous REST through the API gateway only for user-facing reads and protocol-mandated request/response (OCPI commands).
  • Everything else is events on RabbitMQ — session started, meter values received, CDR finalized — with consumers idempotent by design.
  • Every message carries a correlation ID; distributed tracing is the difference between a 10-minute and a 10-hour incident.
  • Sagas over distributed transactions: a failed charging session compensates (releases reservation, voids pre-auth) rather than two-phase commits.

What I’d tell my 2019 self

  • Fewer, larger services. Every service is an on-call surface, a pipeline, and a version matrix.
  • Invest in observability before the second service exists, not the tenth.
  • Contract-test the seams; integration environments lie.
  • The org chart shapes the architecture whether you like it or not — design boundaries teams can actually own.

Worked example: session-stopped event, end to end

One event published by the session service; billing, notifications, and OCPI push each consume independently and idempotently:

// Session service - after committing the state change
await _bus.PublishAsync(new SessionStopped
{
    SessionId   = session.Id,
    ChargerId   = session.ChargerId,
    EnergyKwh   = session.MeterStop - session.MeterStart,
    StoppedAt   = DateTimeOffset.UtcNow
}, routingKey: $"session.stopped.{session.CpoId}");

// Billing service - idempotent consumer
public async Task Handle(SessionStopped evt)
{
    if (await _cdrs.ExistsAsync(evt.SessionId)) return;   // dedupe on business key
    var cdr = CdrFactory.FromSession(evt, await _tariffs.ForSessionAsync(evt.SessionId));
    await _cdrs.SaveAsync(cdr);                            // then side effects
    await _bus.PublishAsync(new CdrCreated(cdr.Id), $"cdr.created.{cdr.CpoId}");
}

The dedupe-check-first pattern makes redelivery safe — which makes every other reliability decision simpler.

FAQ

How many services does an EV charging platform need?

Fewer than you think — my production platforms run well with 6–10 focused services. Beyond that, coordination costs outgrow the scaling benefits for most teams.

REST or messaging between services?

Messaging (RabbitMQ) for anything that can be eventually consistent; REST only where a caller genuinely needs an immediate answer. This single rule prevents most cascading failures.

How do you handle data consistency?

Ownership + events + idempotent consumers + compensating actions. Accept eventual consistency everywhere except the payment path, and design UIs to reflect it honestly.