Service-based and SOA
Between a monolith and microservices sits a pragmatic middle: a handful of coarse services, deployed separately, usually sharing one database. This page builds Pedal that way, breaks the monthly report with an innocent column rename, and fixes it with a read view. It also covers SOA, its service bus, and why the bus became its weak point.
1. In one picture
Service-based architecture splits a system into a few coarse services (often four to twelve), each covering a broad business area and deployed on its own, usually sharing one database. It's the pragmatic middle between a monolith and microservices: independent releases and fault isolation per area, without dozens of services and distributed data.
Service-oriented architecture (SOA) is the older, enterprise-wide cousin: services shared across a whole organisation for reuse, described by formal contracts, and often connected through an enterprise service bus (ESB) that routes, transforms and orchestrates messages. The bus became its weak point: business logic crept into it, one central team owned it, and every change queued behind that team. Microservices grew partly as a reaction: "smart endpoints, dumb pipes".
2. Pedal built this way
Pedal as four services over one database. Follow a return, then a column rename that the Ride team thought was private.
3. The rules
| Rule | Why it exists | Broken when… |
|---|---|---|
| A few coarse services, one per business area | Independent releases without the cost of dozens of services. | Twenty services for one team, or one service that's really the whole monolith. |
| Services call each other through APIs | A service's code can change without breaking callers. | One service imports another's classes from a shared library of business logic. |
| Shared tables have an owner and a contract | The shared database stays changeable. | Any service writes any table, and nobody knows who depends on a column. |
| The pipes stay dumb | Logic lives in services, where its owners can change it. | Routing rules and price calculations live in the ESB configuration. |
4. How it goes wrong
5. The code
Pedal as four services (Ride, Fleet, Billing, Report) over one shared database. The Ride service calls Fleet and Billing through their APIs. Renaming a rides column breaks Reports until it reads a stable view instead.
from __future__ import annotations
# One shared database, as service-based architecture usually has. Each service is deployed separately.
DB: dict[str, list[dict]] = {"bikes": [{"bike": "B7", "dock": "D12"}, {"bike": "B9", "dock": "D12"}], "rides": [], "charges": []}
class FleetService: # bikes and docks
def release(self, dock: str) -> str:
bike = next(b for b in DB["bikes"] if b["dock"] == dock)
bike["dock"] = None
return bike["bike"]
class BillingService: # prices and charges
def charge(self, rider: str, plan: str, minutes: int) -> int:
pence = max(0, minutes - 30) * 10 if plan == "member" else 100 + minutes * 10
DB["charges"].append({"rider": rider, "pence": pence})
return pence
class RideService: # the rider-facing flows; calls the others over their APIs
def __init__(self, fleet: FleetService, billing: BillingService) -> None:
self.fleet, self.billing = fleet, billing
def unlock(self, rider: str, plan: str, dock: str) -> str:
bike = self.fleet.release(dock)
DB["rides"].append({"id": f"R{len(DB['rides']) + 1}", "rider": rider, "plan": plan, "bike": bike, "minutes": None})
return f"{DB['rides'][-1]['id']} on {bike}"
def give_back(self, ride_id: str, minutes: int) -> int:
ride = next(r for r in DB["rides"] if r["id"] == ride_id)
ride["minutes"] = minutes
return self.billing.charge(ride["rider"], ride["plan"], minutes)
class ReportService: # monthly city reports, read straight from the shared tables
def monthly(self, source: str = "rides") -> str:
rows = DB[source]
if rows and "minutes" not in rows[0]:
return "Report service broken: column minutes missing"
return f"{len(rows)} ride(s), {sum(r['minutes'] for r in rows)} minutes"
if __name__ == "__main__":
rides = RideService(FleetService(), BillingService())
print("unlock:", rides.unlock("Mia", "member", "D12"))
print("return: charged", rides.give_back("R1", 42), "pence")
reports = ReportService()
print("report:", reports.monthly())
# The Ride team renames a column in "its" table. Reports shared that table.
for r in DB["rides"]:
r["duration_min"] = r.pop("minutes")
print("after the rename:", reports.monthly())
# The fix: the Ride team publishes a stable read view; Reports reads only that.
DB["ride_report_view"] = [{"id": r["id"], "minutes": r["duration_min"]} for r in DB["rides"]]
print("reading the view:", reports.monthly("ride_report_view"))
// One shared database, as service-based architecture usually has. Each service is deployed separately.
type Row = Record<string, string | number | null>;
const DB: Record<string, Row[]> = { bikes: [{ bike: "B7", dock: "D12" }, { bike: "B9", dock: "D12" }], rides: [], charges: [] };
class FleetService { // bikes and docks
release(dock: string): string {
const bike = DB.bikes.find((b) => b.dock === dock) as Row;
bike.dock = null;
return bike.bike as string;
}
}
class BillingService { // prices and charges
charge(rider: string, plan: string, minutes: number): number {
const pence = plan === "member" ? Math.max(0, minutes - 30) * 10 : 100 + minutes * 10;
DB.charges.push({ rider, pence });
return pence;
}
}
class RideService { // the rider-facing flows; calls the others over their APIs
constructor(private fleet: FleetService, private billing: BillingService) {}
unlock(rider: string, plan: string, dock: string): string {
const bike = this.fleet.release(dock);
DB.rides.push({ id: `R${DB.rides.length + 1}`, rider, plan, bike, minutes: null });
return `${DB.rides[DB.rides.length - 1].id} on ${bike}`;
}
giveBack(id: string, minutes: number): number {
const ride = DB.rides.find((r) => r.id === id) as Row;
ride.minutes = minutes;
return this.billing.charge(ride.rider as string, ride.plan as string, minutes);
}
}
class ReportService { // monthly city reports, read straight from the shared tables
monthly(source = "rides"): string {
const rows = DB[source];
if (rows.length && !("minutes" in rows[0])) return "Report service broken: column minutes missing";
return `${rows.length} ride(s), ${rows.reduce((s, r) => s + (r.minutes as number), 0)} minutes`;
}
}
const rides = new RideService(new FleetService(), new BillingService());
console.log("unlock: " + rides.unlock("Mia", "member", "D12"));
console.log("return: charged " + rides.giveBack("R1", 42) + " pence");
const reports = new ReportService();
console.log("report: " + reports.monthly());
// The Ride team renames a column in "its" table. Reports shared that table.
for (const r of DB.rides) { r.duration_min = r.minutes; delete r.minutes; }
console.log("after the rename: " + reports.monthly());
// The fix: the Ride team publishes a stable read view; Reports reads only that.
DB.ride_report_view = DB.rides.map((r) => ({ id: r.id, minutes: r.duration_min }));
console.log("reading the view: " + reports.monthly("ride_report_view"));import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class Main {
// One shared database, as service-based architecture usually has. Each service is deployed separately.
static final Map<String, List<Map<String, Object>>> DB = new HashMap<>();
static Map<String, Object> row(Object... kv) { Map<String, Object> m = new HashMap<>(); for (int i = 0; i < kv.length; i += 2) m.put((String) kv[i], kv[i + 1]); return m; }
static class FleetService { // bikes and docks
String release(String dock) {
Map<String, Object> bike = DB.get("bikes").stream().filter(b -> dock.equals(b.get("dock"))).findFirst().orElseThrow();
bike.put("dock", null);
return (String) bike.get("bike");
}
}
static class BillingService { // prices and charges
int charge(String rider, String plan, int minutes) {
int pence = plan.equals("member") ? Math.max(0, minutes - 30) * 10 : 100 + minutes * 10;
DB.get("charges").add(row("rider", rider, "pence", pence));
return pence;
}
}
static class RideService { // the rider-facing flows; calls the others over their APIs
private final FleetService fleet; private final BillingService billing;
RideService(FleetService fleet, BillingService billing) { this.fleet = fleet; this.billing = billing; }
String unlock(String rider, String plan, String dock) {
String bike = fleet.release(dock);
List<Map<String, Object>> rides = DB.get("rides");
rides.add(row("id", "R" + (rides.size() + 1), "rider", rider, "plan", plan, "bike", bike, "minutes", null));
return rides.get(rides.size() - 1).get("id") + " on " + bike;
}
int giveBack(String id, int minutes) {
Map<String, Object> ride = DB.get("rides").stream().filter(r -> id.equals(r.get("id"))).findFirst().orElseThrow();
ride.put("minutes", minutes);
return billing.charge((String) ride.get("rider"), (String) ride.get("plan"), minutes);
}
}
static class ReportService { // monthly city reports, read straight from the shared tables
String monthly(String source) {
List<Map<String, Object>> rows = DB.get(source);
if (!rows.isEmpty() && !rows.get(0).containsKey("minutes")) return "Report service broken: column minutes missing";
return rows.size() + " ride(s), " + rows.stream().mapToInt(r -> (Integer) r.get("minutes")).sum() + " minutes";
}
}
public static void main(String[] args) {
DB.put("bikes", new ArrayList<>(List.of(row("bike", "B7", "dock", "D12"), row("bike", "B9", "dock", "D12"))));
DB.put("rides", new ArrayList<>());
DB.put("charges", new ArrayList<>());
RideService rides = new RideService(new FleetService(), new BillingService());
System.out.println("unlock: " + rides.unlock("Mia", "member", "D12"));
System.out.println("return: charged " + rides.giveBack("R1", 42) + " pence");
ReportService reports = new ReportService();
System.out.println("report: " + reports.monthly("rides"));
// The Ride team renames a column in "its" table. Reports shared that table.
for (Map<String, Object> r : DB.get("rides")) r.put("duration_min", r.remove("minutes"));
System.out.println("after the rename: " + reports.monthly("rides"));
// The fix: the Ride team publishes a stable read view; Reports reads only that.
List<Map<String, Object>> view = new ArrayList<>();
for (Map<String, Object> r : DB.get("rides")) view.add(row("id", r.get("id"), "minutes", r.get("duration_min")));
DB.put("ride_report_view", view);
System.out.println("reading the view: " + reports.monthly("ride_report_view"));
}
}Output (the same in all three languages)
unlock: R1 on B7 return: charged 120 pence report: 1 ride(s), 42 minutes after the rename: Report service broken: column minutes missing reading the view: 1 ride(s), 42 minutes
6. Try it
The shared-database blast radius
Pick a table to change. See which services break, with tables shared directly or with each table owned by one service that publishes read views to the others.
7. What it costs, and when to pick another style
What you get
- Independent releases per area: billing ships without waiting for rides.
- Fault isolation: a crash in reports doesn't stop unlocking.
- Still simple data: one database keeps queries, joins and transactions easy where needed.
- Few moving parts: a handful of services is easy to operate and monitor.
What you pay
- Coupling through the database: schema changes need coordination across services.
- Scaling is coarse: a big service scales as a whole.
- Network calls between services: slower and less reliable than in-process calls.
- SOA's bus: a central dependency and team that can slow everyone down.
| If… | Consider instead |
|---|---|
| One team, boundaries still unclear | Modular monolith |
| Many teams, each needing its own data and pace | Microservices |
| Services must react to each other without waiting | Event-driven between services |
| Integrating many existing enterprise systems | SOA-style integration, with logic kept out of the bus |
8. Signs you've outgrown it
- Schema changes routinely need meetings across services.
- One service needs a different database technology, or far more capacity, than the shared one gives.
- A service has grown to cover several teams' work.
9. Remember it and say it
How this hook works: what “A few departments, one records office” means
What the slogan means, phrase by phrase
- “A few departments”
- Four to twelve coarse services.
- “one records office”
- A shared database: convenient, and a source of coupling.
Why this picture fits: A town's departments: each runs itself, but they all use the basement records office.
The story → the concept
| The town | Pedal |
|---|---|
| Departments | Ride, Fleet, Billing and Report services |
| The records office | One shared database |
| A filing change | Renaming rides.minutes |
| A published register | A read view the Ride team promises to keep stable |
Watch it on an example
- Billing releases on its own schedule.
- Reports broke when the Ride team renamed a column it thought was private.
- A read view turned that column into a deliberate contract.
The trap it saves you from
Treating the shared database as private to whoever created a table.
It still works when…
As a stepping stone: many teams go monolith → service-based → microservices only where needed.
In an interview or design review
- Offer it as the middle option: "Four services along business areas, one database; we split data per service only where a service needs it."
- Name the coupling and its fix: owned tables, read views or APIs for others, and schema changes with expand-and-contract.
- Know SOA vs microservices: enterprise reuse and a central bus vs team-owned services with smart endpoints and dumb pipes.
10. Check yourself
What usually distinguishes service-based architecture from microservices?
The Ride team renames a column and the Report service breaks. What's the root problem?
What was SOA's most common failure?
A single team is building a new product with unclear boundaries. Service-based or modular monolith?
11. Go deeper
| Resource | Why |
|---|---|
| Architecture 13: Service-based and SOA | How an architect weighs this style. |
| Architecture 23: Data in distributed architectures | Who owns data, and how others read it. |
| Microservices: smart endpoints and dumb pipes (Lewis and Fowler) | The reaction to ESB-centred SOA. |