Skip to content

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:

  1. Fix the code in the shared package
  2. Run tests
  3. Bump the version
  4. Publish to npm
  5. Update the consumer service’s package.json
  6. Run npm install
  7. Test the integration
  8. 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:

packages/shared-utils/src/validation.ts
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:

Separate NPM Package Workflow
1. Make change in shared package
2. Write tests
3. Update version in package.json (1.0.0 -> 1.0.1)
4. Run `npm publish`
5. Wait for CI to pass
6. Update service-a package.json
7. Run `npm install` in service-a
8. Update service-b package.json (repeat steps 6-7)
9. Update service-c package.json (repeat steps 6-7)
10. Test integration across all services
11. Deploy all services in correct order
Total time: Hours to days
Coordination overhead: High
Risk of version mismatch: High

This 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:

services/user-service/src/utils/validation.ts
export function validateEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
services/order-service/src/utils/validation.ts
export function validateEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
services/notification-service/src/utils/validation.ts
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/ (directory 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.json

The 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:

my-monorepo/package.json
{
"name": "my-monorepo",
"private": true,
"workspaces": [
"packages/*",
"services/*"
]
}
my-monorepo/packages/shared-utils/package.json
{
"name": "@my-org/shared-utils",
"version": "1.0.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"test": "jest"
}
}
my-monorepo/services/user-service/package.json
{
"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:

packages/shared-utils/src/index.ts
// ✅ 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:

services/user-service/src/services/user-service.ts
// ❌ 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:

utils/extraction-decision.ts
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) => false

Monorepo 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:

nx.json
{
"npmScope": "my-org",
"affected": {
"defaultBase": "main"
},
"tasksRunnerOptions": {
"default": {
"runner": "@nrwl/nx-cloud",
"options": {
"cacheableOperations": ["build", "test", "lint"]
}
}
},
"targetDefaults": {
"build": {
"dependsOn": ["^build"]
}
}
}
packages/shared-utils/project.json
{
"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:

Monorepo Workflow
1. Make change in shared package
2. Write tests
3. Update consumer service if needed
4. Run `nx affected:test` (only tests affected projects)
5. Commit and push
6. CI builds and deploys affected services
Total time: Minutes
Coordination overhead: Low
Risk of version mismatch: None

Before 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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments