← Back to blog
5 min readArchitecture

How to write an Organization/Open Source library (Opinionated) | Part 1 — Specification

Opinionated guide to specifying and structuring a TypeScript library for internal org use or open source—README-first, MVP contracts, and layered packages.

This series is an opinionated take on how to approach building a library—whether it ships inside your organization or as open source. Part 1 focuses on specification: how you decide what to build before you drown in implementation details.


Where do I start?

Know your consumer(s)

Before you write components, services, or utilities, invest time in who will consume the package: other teams, external contributors, or future you. Ask:

  • What runtime and bundler do they use?
  • Do they need tree-shaking and ESM/CJS dual builds?
  • What is their skill level with the ecosystem (e.g. Angular, Node)?

That context shapes API surface, peer dependencies, and documentation tone.

Document how you would like to use it

If you are building a library—org-internal or community-facing—you probably already understand the problem space. A practical accelerator is README-driven development: write the usage document you wish existed before the code catches up.

Golden rule: the first README does not need every edge case. It needs a credible happy path that a reader can copy, paste, and adapt.

Example — Slothy Client (fictional)

Slothy Client is a small client that makes task storage and retrieval easier. It keeps pending tasks and lets you work through them.

Installation
npm install @slothy/client --save
Setup
import { createSlothyClient, SlothyClientConfig } from '@slothy/core';

const config: SlothyClientConfig = {
  storage: 'in_memory',
  debug: false,
};

const client = await createSlothyClient(config);
Integrations — Angular
npm install @slothy/angular --save

Then import SlothyClientModule.forRoot(...) in your AppModule (see the Angular package README for full steps).

SlothyClientConfig
Property Type Description
storage 'in_memory' | 'indexed_db' Where tasks are persisted
debug boolean Enables verbose logging

That single page already forces you to name concepts (SlothyClient, createSlothyClient, config shape) that will become your public contract.

MVP before overbuilding

In the first iteration, stay focused on the core problem. Two common traps:

  1. Over-complete specs — even if you know the domain deeply, resist documenting every future feature. Capture the smallest behavior that proves value.
  2. Bloated or vague contracts — prefer small, composable interfaces you can extend later, and document only what v0 truly guarantees.

Layered architecture

Keep your domain isolated

Frameworks and UI libraries change; your domain model and invariants should not leak into every consumer. A typical layout for @slothy/core:

.
└── lib/
    ├── domain/
    │   └── ...
    ├── core/
    │   └── ...
    ├── utils/
    │   └── ...
    └── index.ts

index.ts must export only what you intend to support as public API. Everything else stays internal or behind explicit entry points (@slothy/core/testing, etc.).

export { createSlothyClient, SlothyClient, SlothyClientConfig } from './domain';

Consumer code

import { createSlothyClient, SlothyClientConfig } from '@slothy/core';

const config: SlothyClientConfig = {
  storage: 'in_memory',
  debug: true,
};

const client = await createSlothyClient(config);

client.doSomething();

Make it as pluggable as you can

Pluggability here means: stable core, swappable edges.

  • Prefer functions and factories (createSlothyClient) over hidden singletons.
  • Expose narrow interfaces for storage, logging, and transport so teams can replace them without forking the library.
  • Use peer dependencies for frameworks (Angular, React) so consumers control versions and avoid duplicate installs.

Document one blessed integration path per major ecosystem; keep optional adapters in separate packages.

Write multiple clients/adapters for your domain

If consumers must integrate with Angular, CLI tools, or other stacks, ship thin adapter packages that wrap the core and translate framework-specific configuration into your domain config.

Example layout for @slothy/angular:

.
└── lib/
    ├── utils/
    │   └── ...
    ├── ng-slothy.config.ts
    ├── ng-slothy-client.module.ts
    └── index.ts

ng-slothy.config.ts:

import { SlothyClient, SlothyConfig } from '@slothy/core';

export interface NgSlothyClientModuleConfig extends SlothyConfig {}

export class NgSlothyClient extends SlothyClient {}

export function mapToSlothyConfig(
  ngConfig: NgSlothyClientModuleConfig
): SlothyConfig {
  const config: SlothyConfig = { ...ngConfig };
  return config;
}

ng-slothy.tokens.ts (simplified):

import { InjectionToken } from '@angular/core';
import type { SlothyClient, SlothyConfig } from '@slothy/core';

export const SLOTHY_CONFIG = new InjectionToken<SlothyConfig>('SLOTHY_CONFIG');
export const SLOTHY_CLIENT = new InjectionToken<SlothyClient>('SLOTHY_CLIENT');

ng-slothy-client.module.ts:

import { NgModule, ModuleWithProviders } from '@angular/core';
import { createSlothyClient } from '@slothy/core';
import {
  NgSlothyClientModuleConfig,
  NgSlothyClient,
  mapToSlothyConfig,
} from './ng-slothy.config';
import { SLOTHY_CONFIG, SLOTHY_CLIENT } from './ng-slothy.tokens';

@NgModule()
export class SlothyClientModule {
  static forRoot(
    config?: NgSlothyClientModuleConfig
  ): ModuleWithProviders<SlothyClientModule> {
    const slothyConfig = mapToSlothyConfig(config ?? {});
    return {
      ngModule: SlothyClientModule,
      providers: [
        { provide: SLOTHY_CONFIG, useValue: slothyConfig },
        {
          provide: SLOTHY_CLIENT,
          useFactory: () => createSlothyClient(slothyConfig),
        },
        { provide: NgSlothyClient, useExisting: SLOTHY_CLIENT },
      ],
    };
  }
}

Consumer code

import { SlothyClientModule } from '@slothy/angular';

const SLOTHY_MODULE_CONFIG: NgSlothyClientModuleConfig = {
  param: 'my-awesome-slothy',
  debug: false,
};

@NgModule({
  imports: [
    SlothyClientModule.forRoot(SLOTHY_MODULE_CONFIG),
  ],
})
export class AppModule {}

The Angular package maps framework configuration to core configuration; it does not reimplement domain logic.


What “specification” means in Part 1

  • A README-shaped contract you can iterate on with users.
  • An MVP boundary that avoids premature completeness.
  • A layered package map: core domain first, adapters second.

Continue in Part 2 — Versioning, changelog & testing for versioning, changelog discipline, and testing strategies that keep that contract honest over time, then Part 3 — Contributors & documentation for CONTRIBUTING.md, RFCs, and API documentation.


Tags: open source, library, TypeScript, Angular