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
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.
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.
Ownership + events + idempotent consumers + compensating actions. Accept eventual consistency everywhere except the payment path, and design UIs to reflect it honestly.