Shared Code Without a Monorepo: How We Built smplkit-core
When you have multiple Python services that need to share code — data models, utilities, middleware, error handling — you have a few architectural options. We could have gone monorepo, colocating all our services in a single repository and sharing code through local imports. We could have duplicated the shared code across services and accepted drift. Or we could publish a shared package and install it like any other dependency.
We chose the third option. This post explains why, and what that looks like in practice.
Why Not a Monorepo?
Monorepos are genuinely useful when the shared code and the services that consume it evolve together tightly. If a change to the shared data model always requires simultaneous changes to three services, shipping those as atomic commits in a single repo makes a lot of sense.
That’s not our situation. Our services — app, config, flags, logging — are designed to be independently deployable. Each service has its own ECS task, its own CI/CD pipeline, its own database. They share a platform-level contract (the app service handles accounts; the product services handle product resources) but they don’t share business logic. When config changes, flags shouldn’t need to be touched.
A monorepo would couple our deployment cadences together. It would mean any engineer touching one service’s code would need to understand the full repository structure. And it would make it harder to give each service clean ownership boundaries as the team grows.
Why Not Code Duplication?
We evaluated this more seriously than you might expect. The shared code at the start of the project was small — some error handling helpers, a base exception class, some JSON:API envelope utilities. Duplicating that across four services didn’t seem catastrophic.
The problem is that duplication drifts. One service adds a new exception class. Another adds a utility that the first would also benefit from. Three months later, you have four diverged copies of what started 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 bugs in production before. A bug fix applied to one service’s copy of shared validation logic not applied to another, resulting in inconsistent behavior across the platform. Not acceptable.
smplkit-core: A Published Python Package
Our shared code lives in a separate repository (smplkit/python-core) and is published as a Python package named smplkit-core to a private AWS CodeArtifact repository. Product services install it like any other dependency: pip install smplkit-core. No local imports, no editable installs — it’s a real versioned artifact.
This is the same pattern used by large engineering organizations that don’t want a monorepo but do want code sharing: publish internal packages to an internal registry. PyPI for open source code. CodeArtifact for code that needs to stay private or needs tight access control.
The choice of CodeArtifact over a self-hosted PyPI came down to a few factors. AWS CodeArtifact integrates naturally with IAM for access control — our service deployments already have IAM roles, so granting them CodeArtifact read access is a single policy statement. It also supports upstream proxying: we can configure it to proxy requests to PyPI, so services can install both internal packages and public packages through a single registry endpoint. That simplifies CI/CD configuration.
What Lives in smplkit-core
The package is organized around a few conceptual areas:
JSON:API envelope helpers. Every API response in smplkit follows JSON:API format. The envelope — the wrapper that puts resource data inside {"data": {"type": "...", "id": "...", "attributes": {...}}} — is generated by helpers in smplkit-core. Each service calls the same helper; no service implements its own serialization.
Error handling. A base exception hierarchy (SmplkitError, ValidationError, NotFoundError, ConflictError) with corresponding HTTP status codes. FastAPI exception handlers that convert these into properly-formatted JSON:API error responses. Services raise these exceptions; the framework handles the serialization.
Request logging. Structured logging middleware that adds request IDs, account IDs, and timing information to every log line. Described in more detail in our post on structured logging.
Internal auth. The platform-internal token mechanism used for service-to-service calls inside the VPC. Helper functions for generating and validating these tokens. Each service that needs to call another service’s internal API uses this shared implementation.
Subscription governance. The smplcore.catalog module that defines the product catalog and subscription rules. This is what lets each product service know what features are available at each subscription tier.
Governance: Who Gets to Change It?
A shared package creates a governance question: who decides what goes in it, and how do you prevent it from becoming a dumping ground?
Our rule is that smplkit-core should contain code that is genuinely used by multiple services and that has a stable interface. A utility that’s used in one service and might someday be used in another doesn’t belong there — it lives in the service that uses it, and moves to smplkit-core when a second service actually needs it.
Changes to smplkit-core require updating the version number and re-publishing. Services get the new version when they update their dependency. We use semantic versioning: patch versions for bug fixes, minor versions for new functionality, major versions for breaking changes. In practice, we try hard to avoid major version bumps by designing stable interfaces from the start.
The governance constraint is: if you want to change the behavior of something in smplkit-core in a way that would break existing callers, you need to either (a) make it backward compatible, or (b) bump the major version and update all callers in a single coordinated release. This creates healthy friction around breaking changes.
Version Pinning Policy
We deliberately do not pin smplkit-core to a specific version in our service requirements. Services always install the latest. This might seem risky, but it reflects a deliberate choice: we want services to always be running the most recent version of shared code, including bug fixes and security patches.
The risk is that a bad release of smplkit-core breaks all services simultaneously. We mitigate this through:
- Tests in
smplkit-coreitself. The package has its own test suite that runs on every commit. - Integration tests in consuming services. Each service’s CI runs against the latest
smplkit-core, so a breaking change fails CI before it reaches production. - Staged deployment. Services deploy independently, so even if a bad
smplkit-coreversion gets through, we can catch it during one service’s deployment before all services have updated.
We’ve had one case where a smplkit-core change broke a service’s CI. The fix was straightforward — we caught it before it reached production. The alternative (pinning versions) would have created a different problem: services running behind on shared utilities, each needing manual version bumps to get security fixes.
The No-Editable-Installs Rule
One operational rule we’re strict about: no pip install -e of smplkit-core, ever. No editable installs of internal packages anywhere in our environment.
Editable installs silently bypass the published package, substituting the local source tree. This creates a class of bugs that’s hard to diagnose: code works locally (where the editable install is present) but fails in CI or production (where the published package is installed). The symptoms look like environment differences but are actually version differences.
The correct mechanism for testing changes to smplkit-core before publishing is: publish a release candidate to CodeArtifact (with a .rc1 version suffix), install that release candidate in the service that needs it, run the tests. This is slower than an editable install but it tests the actual artifact that production will run.
What We’d Revisit
The main friction in this model is the publish cycle. Changing something in smplkit-core requires a commit, a CI run, a published artifact, and then a dependency update in each consuming service. For small changes this can feel like overhead.
We’ve considered whether some categories of change — documentation, purely additive utilities — warrant a faster path. So far we’ve maintained the discipline of going through the full cycle for everything, because the discipline is easier to maintain consistently than to apply selectively.
As the team grows, the governance model will need more formal documentation. Right now “check with the person who owns smplkit-core” is a workable heuristic. At ten engineers it won’t be.
All smplkit product services install smplkit-core from AWS CodeArtifact. The shared package is what makes consistent JSON:API formatting, error handling, and auth behavior possible across independently deployed microservices.