InterviewKata
Architecture styles · 07 of 16 · Across machines

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

A town has a handful of big departments (the library, the leisure centre, the council office) instead of one building for everything or a kiosk on every corner. Each runs itself, but they share one records office in the basement. That makes sharing easy, and it means a filing change in the basement can surprise every department at once.

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

RuleWhy it existsBroken when…
A few coarse services, one per business areaIndependent 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 APIsA 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 contractThe shared database stays changeable.Any service writes any table, and nobody knows who depends on a column.
The pipes stay dumbLogic lives in services, where its owners can change it.Routing rules and price calculations live in the ESB configuration.

4. How it goes wrong

The shared database as a hidden APIEvery column becomes a contract nobody wrote down; a rename breaks a service nobody remembered.
Logic in the busSOA's classic failure: transformations and business rules inside an ESB that one central team owns.
Too fine, too soonSplitting into many services before the areas are understood: a distributed monolith.
Chatty servicesService A needing five calls to B for every request, because the boundary is in the wrong place.

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 unclearModular monolith
Many teams, each needing its own data and paceMicroservices
Services must react to each other without waitingEvent-driven between services
Integrating many existing enterprise systemsSOA-style integration, with logic kept out of the bus

8. Signs you've outgrown it

9. Remember it and say it

Memory hook: "A few departments, one records office"Coarse services per business area, deployed separately, sharing a database that needs an owner per table.
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 townPedal
DepartmentsRide, Fleet, Billing and Report services
The records officeOne shared database
A filing changeRenaming rides.minutes
A published registerA read view the Ride team promises to keep stable

Watch it on an example

  1. Billing releases on its own schedule.
  2. Reports broke when the Ride team renamed a column it thought was private.
  3. 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

10. Check yourself

What usually distinguishes service-based architecture from microservices?

Coarser parts and simpler data, with less independence.

The Ride team renames a column and the Report service breaks. What's the root problem?

Owned tables with published views make the contract explicit.

What was SOA's most common failure?

Hence "smart endpoints, dumb pipes".

A single team is building a new product with unclear boundaries. Service-based or modular monolith?

Split when a force demands it.

11. Go deeper

ResourceWhy
Architecture 13: Service-based and SOAHow an architect weighs this style.
Architecture 23: Data in distributed architecturesWho owns data, and how others read it.
Microservices: smart endpoints and dumb pipes (Lewis and Fowler)The reaction to ESB-centred SOA.