Shared code without a monorepo: how we built smplkit-core
When multiple Python services need to share code — data models, utilities, middleware, error handling — you have three options: a monorepo with local imports, duplicating the code and accepting drift, or publishing a shared package and installing it like any other dependency. We chose the third, and along with it one operational rule we enforce without exceptions: nobody, ever, runs pip install -e on an internal package. The rule matters as much as the architecture, and we’ll get to why.
Why not a monorepo
Monorepos earn their keep when shared code and its consumers evolve tightly together — when a data-model change always requires simultaneous changes in three services, atomic commits in one repo make sense.
That’s not our situation. Our services — app, config, flags, logging — are designed to be independently deployable: each has its own ECS task, its own CI/CD pipeline, its own database. They share a platform-level contract (the app service handles accounts; product services handle product resources), not business logic. When config changes, flags shouldn’t need to be touched. A monorepo would couple deployment cadences, force anyone touching one service to understand the whole repository, and blur ownership boundaries just as the team grows into needing them.
Why not duplication
We took this option more seriously than you might expect. The shared code at the start was small — error-handling helpers, a base exception class, some JSON:API envelope utilities. Copying that into four services didn’t seem catastrophic.
The problem is that duplication drifts. One service adds an exception class. Another adds a utility the first would also want. Three months later you have four diverged copies of what began as identical code, and any time you want to add something to “shared code” you have to remember to add it to four places. And you will forget.
We’ve seen this pattern produce production bugs before: a bug fix applied to one service’s copy of shared validation logic and not another’s, resulting in inconsistent behavior across the platform. Not acceptable.
A real package in a real registry
Our shared code lives in its own repository (smplkit/python-core) and is published as smplkit-core to a private AWS CodeArtifact repository. Services install it like any other dependency: pip install smplkit-core. No local imports, no editable installs — a versioned artifact, same as anything from PyPI.
This is the pattern large engineering organizations use when they want code sharing without a monorepo: internal packages in an internal registry. CodeArtifact won over self-hosted PyPI for two boring, decisive reasons. It speaks IAM — our deployments already have IAM roles, so granting CodeArtifact read access is one policy statement. And it proxies upstream to PyPI, so services install internal and public packages through a single registry endpoint, which keeps CI/CD configuration simple.
What’s in it
JSON:API envelope helpers — every API response in smplkit follows JSON:API, and the envelope that wraps resource data in {"data": {"type": "...", "id": "...", "attributes": {...}}} comes from these helpers. No service rolls its own serialization.
Error handling — the base exception hierarchy (SmplkitError, ValidationError, NotFoundError, ConflictError) with HTTP status mappings, plus the FastAPI handlers that turn them into properly-formatted JSON:API error responses.
Request logging — structured logging middleware that stamps request IDs, account IDs, and timing onto every log line, described in more detail in our post on structured logging.
Internal auth — the platform-internal token mechanism for service-to-service calls inside the VPC: one shared implementation for generating and validating tokens.
Subscription governance — the smplcore.catalog module defining the product catalog and subscription rules, which is how each product service knows what a subscription tier allows.
The no-editable-installs rule
Here’s the rule we’re strictest about, and the reason it exists.
pip install -e silently substitutes your local source tree for the published package. Everything works on your machine — where the editable install is present — and fails in CI or production, where the real package is installed. The symptoms look exactly like environment differences, and you’ll debug them as environment differences, but they’re version differences wearing a costume. That failure mode is expensive precisely because it’s misleading, so we banned the thing that causes it. Everywhere, with no “just this once.”
The correct way to test a smplkit-core change before release: publish a release candidate to CodeArtifact with a .rc1 suffix, install that in the consuming service, run the tests. Slower than an editable install — and it tests the actual artifact production will run.
Governance
The rule for what belongs in smplkit-core: code genuinely used by multiple services, with a stable interface. A utility used by one service that might someday interest another stays in that service, and moves to core when a second consumer actually appears — not before. That’s what keeps a shared package from becoming a dumping ground.
Changes are published under semantic versioning: patch for fixes, minor for new functionality, major for breaking changes. Breaking a smplkit-core interface means either making it backward compatible or bumping the major and updating every caller in one coordinated release. That friction is intentional, and in practice we try hard to design interfaces that avoid major bumps.
No pins
Services deliberately do not pin smplkit-core to a specific version — they always install the latest. That sounds risky, and the risk is real: a bad release breaks every service at once. We accept it because the alternative trades it for a different problem — services running stale shared code, each needing a manual bump to pick up bug fixes and security patches.
Three things keep the risk manageable: the package has its own test suite on every commit; each consuming service’s CI runs against the latest core, so a breaking change fails CI before it ships; and services deploy independently, so a bad version that somehow survives gets caught during one service’s deployment rather than all of them. We’ve had one case of a core change breaking a service’s CI. It was caught there, fixed, and never reached production.
What we’d revisit
The friction in this model is the publish cycle: a change to smplkit-core means a commit, a CI run, a published artifact, and a dependency update in each consumer. For small changes that can feel like overhead, and we’ve been tempted to carve out a faster path for documentation or purely additive utilities. So far we’ve kept everything on the full cycle, because the discipline is easier to maintain consistently than to apply selectively.
And the governance model is currently “check with the person who owns smplkit-core,” which works fine today. At ten engineers it won’t, and it’ll need writing down before then.