When Should You Create Shared Libraries in Microservices? (The 3-Service Rule)
I was stuck in a version hell. My team had created a shared npm package for common utilities across our microservices, and every time I needed to fix a bug in the validation logic, I had to:
- Fix the code in the shared package
- Run tests
- Bump the version
- Publish to npm
- Update the consumer service’s package.json
- Run npm install
- Test the integration
- Deploy
Meanwhile, two other services were still using the old version. The coordination overhead was killing our velocity.
I thought I was doing the right thing by following DRY (Don’t Repeat Yourself). But I was wrong. The shared package had become a bottleneck, not a benefit.
The Problem: Shared npm Package Bottleneck
Here’s what our architecture looked like:
export function validateEmail(email: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);}Seems innocent enough. But when I needed to add support for international email formats, I had to touch multiple repositories, coordinate version updates, and deal with version mismatch issues across services.
The workflow looked like this:
1. Make change in shared package2. Write tests3. Update version in package.json (1.0.0 -> 1.0.1)4. Run `npm publish`5. Wait for CI to pass6. Update service-a package.json7. Run `npm install` in service-a8. Update service-b package.json (repeat steps 6-7)9. Update service-c package.json (repeat steps 6-7)10. Test integration across all services11. Deploy all services in correct order
Total time: Hours to daysCoordination overhead: HighRisk of version mismatch: HighThis isn’t just my experience. Every team I talked to had the same story. Shared npm packages in microservices almost always become bottlenecks.
The 3-Service Rule
Then I discovered a principle that changed everything: copy-paste until you’ve copied the same code three times across three or more services, THEN extract it.
This is known as the “3-service rule,” and it’s rooted in Kent C. Dodds’ AHA principle: “Avoid Hasty Abstractions.”
The idea is simple: premature abstraction hurts more than duplication. Wait until you have enough data points to understand the true abstraction needed.
Starting with Copy-Paste
Here’s how I restructured after learning this rule:
export function validateEmail(email: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);}export function validateEmail(email: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);}export function validateEmail(email: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);}Yes, this is code duplication. But each service could evolve independently. No coordination needed. Deploy whenever ready. The flexibility was worth the duplication.
When to Extract: Meeting the 3-Service Rule
Once I had the same validateEmail function in three services, it was time to extract. But instead of creating a separate npm package, I moved to a monorepo structure:
my-monorepo/├── packages/│ └── shared-utils/│ ├── src/│ │ ├── validation.ts│ │ ├── formatting.ts│ │ └── index.ts│ ├── package.json│ └── tsconfig.json├── services/│ ├── user-service/│ │ ├── package.json // depends on @my-org/shared-utils│ │ └── src/│ ├── order-service/│ │ ├── package.json // depends on @my-org/shared-utils│ │ └── src/│ └── notification-service/│ ├── package.json // depends on @my-org/shared-utils│ └── src/├── package.json // workspace root├── pnpm-workspace.yaml // or nx.json for NX└── tsconfig.base.jsonThe key difference? No version/publish/update cycle. The monorepo allows atomic commits across services and libraries.
Setting Up the Monorepo
Here’s how I configured the workspace:
{ "name": "my-monorepo", "private": true, "workspaces": [ "packages/*", "services/*" ]}{ "name": "@my-org/shared-utils", "version": "1.0.0", "main": "./dist/index.js", "types": "./dist/index.d.ts", "scripts": { "build": "tsc", "test": "jest" }}{ "name": "user-service", "dependencies": { "@my-org/shared-utils": "workspace:*" }}The workspace:* protocol tells pnpm (or npm/yarn) to use the local package directly, without publishing. Changes to the shared library are immediately available to all services.
What Belongs in Shared Libraries
Not all duplicated code should be extracted. I learned to evaluate code against specific criteria:
// ✅ GOOD candidates for shared libraries:// 1. Used in 3+ services// 2. Stable API (rarely changes)// 3. No service-specific logic
// === Logging (used everywhere, stable API) ===export { createLogger, Logger } from './logging';
// === Validation (reusable, no business logic) ===export { validateEmail, validatePhone, validateUUID } from './validation';
// === Date/Time formatting (stable, widely used) ===export { formatDate, parseDate, toISOString } from './date-utils';
// === Error types (domain-agnostic) ===export { BaseError, HttpStatusCode } from './errors';
// === HTTP client wrapper (standardized API calls) ===export { HttpClient, RequestOptions } from './http-client';What Should Stay Service-Specific
I made the mistake of extracting business logic to shared libraries. Don’t do this:
// ❌ BAD: Don't extract service-specific logic to shared-utils// This belongs in user-service, NOT shared-utils
export class UserService { async createUser(data: CreateUserDto): Promise<User> { // User-specific business logic const hashedPassword = await this.hashPassword(data.password); const user = await this.userRepository.create({ ...data, password: hashedPassword }); await this.eventBus.publish('user.created', user); return user; }}Business logic should stay close to where it’s used. Even if two services have similar code, if it contains business rules, keep it duplicated.
The Decision Matrix
I created a helper function to decide when to extract code:
interface ExtractionCriteria { serviceCount: number; // How many services use this code? changeFrequency: 'rarely' | 'sometimes' | 'often'; businessLogic: boolean; // Does it contain business rules? apiStability: 'stable' | 'evolving' | 'unstable'; teamOwnership: 'single' | 'multiple';}
function shouldExtractToSharedLibrary(criteria: ExtractionCriteria): boolean { // Rule 1: Must be used in 3+ services if (criteria.serviceCount < 3) { return false; }
// Rule 2: Should not contain business logic if (criteria.businessLogic) { return false; }
// Rule 3: API should be stable if (criteria.apiStability === 'unstable') { return false; }
// Rule 4: Changes should be infrequent if (criteria.changeFrequency === 'often') { return false; }
return true;}
// Examples:const dateUtilsCriteria: ExtractionCriteria = { serviceCount: 4, changeFrequency: 'rarely', businessLogic: false, apiStability: 'stable', teamOwnership: 'multiple'};// shouldExtractToSharedLibrary(dateUtilsCriteria) => true
const orderPricingCriteria: ExtractionCriteria = { serviceCount: 3, changeFrequency: 'often', businessLogic: true, apiStability: 'evolving', teamOwnership: 'single'};// shouldExtractToSharedLibrary(orderPricingCriteria) => falseMonorepo Tooling with Nx
For larger teams, I recommend Nx for monorepo management. It provides affected project detection, which only runs tests/builds for projects affected by a change:
{ "npmScope": "my-org", "affected": { "defaultBase": "main" }, "tasksRunnerOptions": { "default": { "runner": "@nrwl/nx-cloud", "options": { "cacheableOperations": ["build", "test", "lint"] } } }, "targetDefaults": { "build": { "dependsOn": ["^build"] } }}{ "root": "packages/shared-utils", "sourceRoot": "packages/shared-utils/src", "projectType": "library", "targets": { "build": { "executor": "@nrwl/js:tsc", "outputs": ["{workspaceRoot}/dist/packages/shared-utils"], "options": { "main": "packages/shared-utils/src/index.ts", "outputPath": "dist/packages/shared-utils" } }, "test": { "executor": "@nrwl/jest:jest", "outputs": ["coverage/packages/shared-utils"] } }}With Nx, the workflow becomes:
1. Make change in shared package2. Write tests3. Update consumer service if needed4. Run `nx affected:test` (only tests affected projects)5. Commit and push6. CI builds and deploys affected services
Total time: MinutesCoordination overhead: LowRisk of version mismatch: NoneBefore and After: The Real Difference
The monorepo approach eliminated the version/publish/update cycle entirely. When I fix a bug in the shared validation logic now:
- Single atomic commit updates both the library and affected services
- CI automatically detects affected projects
- No version numbers to manage
- No npm publish step
- No coordination between teams
The velocity improvement was immediate and dramatic.
Summary
The 3-service rule for shared libraries prevents premature abstraction while ensuring genuine duplication gets addressed:
Before extraction (copy-paste is fine):
- Fewer than 3 services use the code
- The code is still evolving
- Contains business logic specific to one domain
Extract when:
- 3+ services use identical code
- API is stable and unlikely to change frequently
- No service-specific business logic
- Pure utilities (logging, validation, formatting)
Always use monorepo:
- Eliminates version/publish/update cycle
- Atomic commits across services and libraries
- Simplified dependency management
- Faster CI/CD with affected project detection
Remember: duplication is often cheaper than the wrong abstraction. Wait until you have enough data points (3+ services) to understand the true abstraction needed.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 AHA Programming
- 👨💻 Nx Documentation
- 👨💻 pnpm Workspaces
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments