Services & data products
The services, the order-service template, and the anatomy of a data product — its ports, its internal transformation, and the container image that ships it.
Part of the capstone.
With the principles and the Kubernetes mapping in place, this page gets concrete: what the data products in this capstone actually are, the one service we build end-to-end as a template for the rest, and how a data product is packaged and shipped as a container image. This is the first implementation-heavy page — the conceptual scaffolding is behind us.
What a data product looks like here
A data product, in the abstract, is the architectural quantum of a data mesh: the smallest unit you can independently deploy and operate, carrying everything it needs to do its job. It has input ports (where data comes in), output ports (where it serves data out), the transformation logic between them, and the metadata and policies that make it discoverable and governed.
In this capstone, that abstraction is concrete: each domain service is a data product. It owns a slice of the database (its input/internal state), it serves data through its APIs (output ports), it emits events as other domains’ input, and it publishes a contract and metadata so it can be discovered and depended on. The service boundary and the data-product boundary are the same boundary — which is the cleanest way to make domain ownership real rather than aspirational.
The domain
The domain is order-placement-through-fulfillment, modeled deliberately small so the architecture stays legible. There are five domain services, each a bounded context owning its data and its contract, plus one gateway that composes reads across them (the gateway is a read-layer convenience, not a domain data product — six images in all). The five domains:
| Service | Domain | Owns | Talks via |
|---|---|---|---|
| order-service | Order lifecycle | the orders schema, the order state machine |
REST in from clients, gRPC out to inventory/payment/shipping, publishes orders.placed |
| inventory-service | Stock levels | the inventory schema |
gRPC server, publishes inventory.updated, consumes orders.placed |
| payment-service | Payments | the payments schema |
gRPC server, publishes payments.processed, consumes orders.placed |
| shipping-service | Shipments | the shipments schema |
gRPC server, publishes shipments.dispatched, consumes payments.processed |
| notification-service | Notifications | the notifications schema |
Kafka consumer only — reacts to events, emits notifications |
The variation is deliberate: not every service exposes every protocol. Each exposes the protocols that fit its role, not a uniform surface. notification-service is event-only because its job is to react, not to be called synchronously; the gateway exists to compose reads so clients don’t have to fan out across five services. The reasoning behind which protocol goes where is the subject of the data planes page; here the point is just that the surface follows the role.
Each service owns its own schema in a shared Postgres cluster — one schema per domain, so the database is partitioned by ownership even though it’s one managed cluster. That “one cluster, one schema per service” choice is what keeps per-domain data ownership real without running five separate databases on a single learning node.
Build one service end to end first
Rather than build all six services a layer at a time, the capstone takes a single service all the way through first — a walking skeleton. The point is to prove the entire spine works before widening: build the image, get it to the cluster, deploy via helm, have the operator-managed Postgres come up, the service connect, and data round-trip through a real API call. Once that path is verified on real hardware, the remaining services are mechanical repetition of the same pattern.
order-service is that template. It’s a Python service that owns the orders
schema. It starts speaking only REST, and gRPC, GraphQL, and event publishing get
layered on in later steps — but the deployment spine is proven first with the simplest
possible surface.
A couple of packaging choices carry across all six images. Dependencies are managed with a lockfile so builds are reproducible, and the lockfile is exported into the image rather than carrying the dependency manager into the runtime. The image itself is multi-stage: a builder stage resolves dependencies, and a slim runtime stage copies only the resolved environment and the application code, runs as a non-root user, and serves the app. Standard production hygiene — the capstone just applies it from the start rather than retrofitting it.
Getting images to the kubelet
The capstone runs on minikube’s Docker driver with the containerd runtime, on Docker
Engine, so the image path has two steps and no registry. Build on the host with
docker build -f Containerfile, then copy the image into the profile’s containerd
store with minikube -p mof-capstone image load. One script does both:
./scripts/build-image.sh services/order-service order-service v1
Three conventions keep this predictable. Images use bare names with an explicit tag
(order-service:v1), the charts set imagePullPolicy: Never so the kubelet only ever
uses what was loaded, and the script runs kubectl rollout restart after a reload,
because with Never a running pod keeps its old image until it restarts. Loaded
images live in the node’s containerd store, so they should survive minikube stop and
minikube start; cluster-up.sh re-loads any that are missing.
The base image is ubi10/python-314-minimal (Python 3.14), with ubi9/python-314 as
the documented fallback. Python 3.14 is why asyncpg is pinned at 0.32: that is the
release that ships a cp314 wheel.
An earlier iteration of this build pushed images to the registry addon under a different driver and hit a run of driver-specific failures. Those are kept as lessons, not repeated here; see Part 4 of LESSONS-LEARNED.
With a service shipped and running, the next question is how products describe themselves so others can find and trust them — contracts and the catalog.