Per-Package Manifests with App-Level Composition
Status: Design spec, signed off. Implementation pending. Target: Next major version of ioc-manifest. Hard cut, no backward compatibility.
1. Motivation
The current ioc-manifest model assumes one config per app, with scanDirs permitted to reach across package boundaries. In a monorepo where an app consumes factories from another package, the app's config scans into the other package's src/. This produces three problems.
1.1 TS resolution cache poisoning
The generated ioc-registry.types.ts emits bare-specifier imports (import type { Foo } from '@packages/media-core'). Bare specifiers bypass tsconfig path mappings — Node-style resolution walks node_modules, finds the workspace symlink, follows package.json exports.types to the package's dist/index.d.ts. TypeScript caches the resolution. Subsequent imports of the same specifier reuse the cache and also land in dist/.
Meanwhile, the scan roots pull src/*.ts into the program directly. The program now contains two declarations of every shared type — one from dist/, one from src/. groups resolution fails with ambiguous-base-type errors.
The current workaround (clean dist/ before codegen, rebuild before app bundling) is documented in the consuming repo's MONOREPO.md. It works but is operationally awkward and addresses the symptom rather than the design flaw.
1.2 Package boundary violation
Cross-package scanning embeds another package's internal source paths into the consuming app's generated artifacts. If the consumed package is later published independently, or if isolation policy tightens, this breaks. Multiple apps consuming the same package re-scan it redundantly and risk divergent registration.
1.3 Plugins are impossible
Any use case where a package contributes factories to a host (dynamic plugins, optional features, environment-specific implementations) requires the host to know about the package's source at codegen time. Runtime-discovered plugins cannot participate.
2. Design overview
Each package containing factories generates its own manifest. Apps compose manifests at bootstrap.
import { createContainer } from "awilix";
import { registerIocFromManifest } from "ioc-manifest";
import { composedManifests, type AppCradle } from "./generated/ioc-composed.js";
const container = createContainer<AppCradle>();
registerIocFromManifest(container, composedManifests);composedManifests is generated by codegen from a declaration in the app's ioc.config.ts. It is an array of manifest values, one per composed package, imported via standard package subpath exports (@packages/media-core/iocManifest).
This shape:
- Eliminates §1.1. Each package's codegen runs against its own tsconfig and scans only its own source. Generated
ioc-registry.types.tsimports use relative paths for types declared in the same package; when a factory already imports a type via a bare package specifier (workspace alias ornode_modulesname), codegen preserves that specifier in generated imports instead of emitting a deep relative path through another package'ssrc/. - Eliminates §1.2. No app reaches into another package's source. The package's manifest is its public API for IoC composition, alongside its normal type exports.
- Enables §1.3. A plugin is a package that exports a manifest. An app can compose any set of manifests.
3. The factory-site pattern
Every build* factory must declare its first parameter as a named local deps type, not IocGeneratedCradle directly.
// media-core/src/services/buildValidateOperationService.ts
type ValidateOperationServiceDeps = {
mediaItemReadRepository: MediaItemReadRepository;
grantReadRepository: GrantReadRepository;
albumMemberReadRepository: AlbumMemberReadRepository;
};
export const buildValidateOperationService = ({
mediaItemReadRepository,
grantReadRepository,
albumMemberReadRepository,
}: ValidateOperationServiceDeps): ValidateOperationService => ({
/* ... */
});Codegen errors if a factory destructures from IocGeneratedCradle directly. The error message names the factory and explains the named-deps pattern.
Rationale: a package's IocGeneratedCradle is codegen output, not input. The cradle's shape derives from the union of all factories' deps. Factories cannot reference the cradle as their input type without a chicken-and-egg dependency. Beyond mechanics, named deps types are independently importable for factory-level testing (see §10) and make each factory's contract explicit at its declaration site.
4. Demand/supply model
Codegen walks every factory in the package and collects two kinds of (key, type) pairs:
- Demands: every property in every factory's deps type.
- Supplies: every factory's return type, keyed by its registration key.
No local-vs-external distinction at the factory site. A factory destructuring database declares a demand for database: Knex regardless of whether the package itself supplies database or not.
4.1 Validation rules during codegen
- Type agreement across factories. Every factory that demands
databasemust agree on its type. IfbuildAsaysdatabase: KnexandbuildBsaysdatabase: PostgresClient, codegen errors with both locations and the conflicting types. - Resolvability. Every type referenced in a deps type must be resolvable by the TS program. Unresolvable types error with the factory location.
- No errors for unsatisfied demands. A demand with no local supplier is recorded as an external. Codegen does not error; satisfaction is a composition-time concern.
4.2 Generated artifacts per package
Three artifacts emitted to the package's generatedDir:
ioc-manifest.ts— runtime manifest value. Same shape as today's manifest (factories, contracts, lifetimes, module imports). Per-package, not per-app.ioc-registry.types.ts— exportsIocGeneratedCradle(flat interface of locally-supplied registration keys and their types) andIocExternals(demanded keys with no local supplier).- (apps only)
ioc-composed.ts— see §6.
Import emission in ioc-registry.types.ts. For each type referenced in factory deps (and contract return types used during discovery), codegen resolves the type's declaration file and normally emits a relative import from generatedDir. If the factory file already imports that type via a bare module specifier (@packages/foo, knex, including subpaths such as @packages/foo/types), codegen reuses that specifier verbatim in the generated file. Type-only imports, renamed bindings (import { X as Y }), and default imports are handled the same way; emitted named imports use the original export name, not the factory's local alias. Relative imports are used only when no matching bare import exists in the factory. If the fallback relative path would escape the package root, codegen emits [ioc-warn] and still completes (see implementation in writeManifest).
4.3 IocGeneratedCradle shape
A single flat interface containing only locally-supplied keys (factory return types and contract access slots). Externally-demanded keys are not in the cradle; they appear only in IocExternals. Factories never destructure from IocGeneratedCradle directly (§3); their named deps types may reference external keys, but those keys are satisfied at app composition time.
Externals are excluded from the cradle so that AppCradle = LocalCradle & LibACradle & … does not already contain a library’s external keys via that library’s own IocGeneratedCradle. If externals were in each library cradle, the compile-time satisfaction assertion in §6 would be vacuously true.
// media-core/generated/ioc-registry.types.ts
import type { AlbumRepository } from "../services/buildAlbumRepository.js";
import type { AlbumService } from "../services/buildAlbumService.js";
import type { Knex } from "knex";
import type { Logger } from "../types/Logger.js";
export interface IocGeneratedCradle {
albumRepository: AlbumRepository;
albumService: AlbumService;
}
export interface IocExternals {
database: Knex;
logger: Logger;
}4.4 Demand-only-from-supplier degenerate case
A package may supply albumRepository (via buildAlbumRepository) and demand it (via buildAlbumService). Codegen records both, sees the supply satisfies the demand internally, emits albumRepository to IocGeneratedCradle but not to IocExternals. Internally-satisfied demands do not leak as external contracts on consumers.
4.5 Supply-without-demand degenerate case
A package may supply albumRepository with no internal consumer (the consumers live in apps that import media-core). Fine. The key is in IocGeneratedCradle and in the manifest. Apps consume it via the composed cradle.
4.6 The demand model: five declared things
A deps property is exactly one of five things, and which one is declared at the site and verified statically. Nothing is inferred from whether a name happens to be registered.
| written | means |
|---|---|
contract key (authMiddleware: AuthMiddleware) | the contract's elected default, whichever implementation that is |
Named<T> implementation key (strictAuthMiddleware: Named<AuthMiddleware>) | that specific implementation |
group root key (channels: Channels) | the group |
scope-root opener key (openAuthRouterScope: OpenAuthRouterScope) | the opener |
| anything else | an external — an unregistered demand the composing app supplies |
Rows one and two are available only to ungrouped contracts. A grouped contract is consumed through its group and through nothing else — no contract key, no member keys — so for its members the third row is the only one there is. See §8.5.
The first two rows used to be spelled identically. Which one a property meant was then decided by whether its name happened to match a registration key, which is invisible at the site and changes when someone renames a factory. Named<T> is the missing declaration; with it, the bare implementation-key spelling is a hard error rather than an accident that works.
Contract slot keys and the election rule
The contract key — the slot key — is the contract's accessKey when one is configured, otherwise the camel-cased contract name. One derivation serves every layer (resolveContractAccessKey / resolveManifestAccessKey), because the point of the key is that the emitted cradle, the demand/supply supply set, the scope-root subtree walk and registerContractDefaultAliases all name the same property.
The slot key exists if and only if the contract is ungrouped and elects a default. Election is the rule selectDefaultImplementationName already applies: exactly one default: true, else exactly one implementation registered under the contract key, else exactly one implementation at all. Where no slot key exists it exists nowhere — not in the cradle, not in the supply set, not on the container. Three shapes reach that state:
- a grouped contract, which is categorically slotless regardless of how many implementations it has or what it declares (§8.5) — a demand for its would-be contract key is diagnosed as the group mistake it is;
- a multi-implementation ungrouped contract with zero or several
default: true, which never reaches emission at all — generation hard-errors, andioc validatereportsdefault-ambiguityagainst an already-written manifest; - for completeness, an unresolvable contract whose plan cannot name an elected implementation.
Scope-rooted contracts have no slot key either, by construction rather than by rule: a scope root claims no registration key and reaches no registration plan, so there is nothing for a slot to be derived from. Scope-rooted contracts are opener-only.
Slot keys join global key-uniqueness through the machinery that already owned it: a slot key colliding with a registration key, a group root, or an emitted opener key is the existing conflict, reported by the existing error.
A slot key that some implementation is already registered under — the convention case, buildMediaStorage → mediaStorage for MediaStorage — is not an alias. Awilix cannot hold two registrations under one name, so that registration owns the key, and every layer resolves the name as a registration before it consults a slot. The cradle then carries that registration's own supply type. Only an alias slot is emitted as the contract type.
An implementation occupying its contract's slot key must be the electee. The occupant owning the key is fine when it is the elected default — the slot and the key coincide by agreement, and that is the sanctioned single-name case. When some other implementation is elected, the two facts contradict each other: the key hands out the occupant while the election names someone else, and row one of the table above — "the contract's elected default, whichever implementation that is" — is simply false for that contract. There is no honest reading in which both hold, so it is a hard error rather than a documented quirk. The error names both exits, because which one is meant is not the tool's to guess: rename the factory so its key stops shadowing the slot, or elect the occupant.
ioc generate gates it off the registration plan, in library and app mode alike — the occupant, the slot key and the election are all package-local facts, and a library shipping the shape exports a manifest whose contract key hands the wrong implementation to every app that composes it. The composition suite gates the shape composition can create out of two packages that are each coherent alone: a library registering and electing mediaStorage, and an app electing something else over it. Same rule, same sentences, reported as [slot-occupancy].
With that error in place, registerContractDefaultAliases has no corner to decide: whenever an implementation is registered at the access key, it is the electee, so boot writing no alias there is the same answer as writing one. The branch remains only because boot is not the gate — it accepts any manifest handed to it, including one from an older version.
Named<T> and strict contract identity
export type Named<T> = T;Transparent to TypeScript, so annotating with it changes nothing about assignability, inference, or what the factory receives. The generator recognizes it syntactically, off the written annotation, by written name and with no checker involvement — the same rule and the same shadowing trade Promise<T> and ScopeRoot<TContract, TLbv> already make.
key: Named<C> verifies that key is an implementation registration key — local, or composed (a composed manifest carries each unit's contractName) — whose declared contract is exactly C. Identity, not assignability: a supertype the implementation happens to satisfy is a different statement, and accepting it would make the annotation stop meaning what it says the moment the implementation's own contract changed underneath it.
The marker anywhere it does not belong is a hard error: on a contract slot key, on a group root key, on an opener key, or on a name no implementation is registered under. Wrong arity follows the scope_root_wrong_arity precedent — writing the marker at all is an unambiguous attempt to declare a named-instance demand, so an unreadable declaration is demanded rather than guessed at.
Where marker recognition sits
Per deps property, in order: the demand is recorded; the marker is read off the written type node; the generated-reference claim parsers run (IocGeneratedCradle["k"], group alias, opener alias); the five-row rule is applied; and only then is the property handed to the checker for emission.
Marker recognition precedes the claim parsers because the two answer different questions — a claim parser asks "which cradle key does this type name?", the marker asks "which of the five things is this property?" — and because a misplaced or wrong-arity marker must be reported as itself rather than as a downstream unresolvable-type error. The two can never both hold: every claim parser rejects a type reference carrying type arguments, and Named<T> always carries one.
A property a claim parser does claim is exempt from the marker requirement, implementation keys included. IocGeneratedCradle["s3Storage"] is already an explicit, enumerated statement of which cradle key is wanted; the ambiguity the marker removes is not present, and a second declaration on top of it would be ceremony.
None of this touches the scope-root walk's own precedence (classifyDemandedKey: group → registration → contract access key → declared group → lbv → external). That walk consumes dependencyKeys, which are binding-pattern names carrying no types, so no marker can reach it; and the slot key sits where it always sat, after registrations — the same answer either way for a key that is both.
Emission never sees the marker. Named<T> is T to the checker, so the deps echo (dependencyContractNames), the cradle property, and every emitted type reference carry the contract, by reference. The marker is a discovery-time declaration, not an emitted artifact.
5. Composition API
function registerIocFromManifest(
container: AwilixContainer<AppCradle>,
manifests: readonly IocManifest[],
): void;The signature takes an array for ergonomic reasons. The semantics are set-like: ordering is irrelevant, duplicates are deduplicated, and conflict resolution never depends on array position. This is a deliberate constraint to prevent silent override-by-ordering bugs.
5.1 Same contract, multiple implementations across manifests
Media-core registers s3MediaStorage and localMediaStorage (both MediaStorage). App's own factories register mockMediaStorage (also MediaStorage).
All three are registered; a collection group over MediaStorage resolves to all three. Default selection precedence:
- App's
ioc.configdeclaresMediaStorage: { mockMediaStorage: { default: true } }→ app wins. - App makes no declaration → if exactly one manifest declares a default for the contract, that wins. If multiple manifests declare conflicting defaults, composition errors and requires app resolution.
- Single implementation across all manifests → that implementation is the default.
- Convention fallback (key matches camel-cased contract name) → applies only if no manifest has declared a default.
Cross-manifest contract overlap is expected and useful. It is the mechanism by which plugin packages extend host behavior. Not an error.
5.2 Same registration key from two manifests
Two manifests both supplying the key albumRepository is a hard error by default. The library refuses to compose, naming both manifests and the conflicting key.
Resolution via the app's ioc.config:
registrations: {
AlbumRepository: {
albumRepository: { source: 'local' }, // or '@packages/media-core'
},
}The source field identifies which manifest's registration wins. Values: 'local' (the app's own factories) or any package name from composedManifests.
Rationale: silent last-write-wins produces production bugs where mocks unintentionally replace real implementations. Forcing the override into config makes the decision visible and reviewable.
The same-key-same-type case is the only one composition must handle. Same-key-different-type is caught earlier by the type system, via the cradle assertion in ioc-composed.ts.
5.3 Order independence
Since conflicts always error and resolve via config, [a, b] and [b, a] produce identical registrations. Documentation states this explicitly. The implementation may iterate in any deterministic order (for stable error messages) but the semantics are set-like.
5.4 Strategy ordering
When a consumer needs strategies in a specific order, ordering metadata belongs on the strategy interface itself, not in the composition API. interface DiscountStrategy { readonly priority: number; ... }; consumers sort at use time. The library does not attempt to order collections.
6. App-level composition glue
Apps declare composed packages in their ioc.config:
export default defineIocConfig({
discovery: { scanDirs: "src" },
composedManifests: ["@packages/media-core", "@packages/infra"],
registrations: {
/* ... */
},
});Codegen produces ioc-composed.ts:
/* AUTO-GENERATED. DO NOT EDIT. */
import { iocManifest as apiManifest } from "./ioc-manifest.js";
import { iocManifest as mediaCoreManifest } from "@packages/media-core/iocManifest";
import { iocManifest as infraManifest } from "@packages/infra/iocManifest";
import type { IocGeneratedCradle as ApiCradle } from "./ioc-registry.types.js";
import type { IocGeneratedCradle as MediaCoreCradle } from "@packages/media-core/iocTypes";
import type { IocGeneratedCradle as InfraCradle } from "@packages/infra/iocTypes";
import type { IocExternals as MediaCoreExternals } from "@packages/media-core/iocTypes";
import type { IocExternals as InfraExternals } from "@packages/infra/iocTypes";
export const composedManifests = [
apiManifest,
mediaCoreManifest,
infraManifest,
] as const;
export type AppCradle = ApiCradle & MediaCoreCradle & InfraCradle;
// Compile-time externals satisfaction assertions
type _IocExpect<T extends true> = T;
type _MediaCoreExternalsSatisfied =
MediaCoreExternals extends Pick<AppCradle, keyof MediaCoreExternals>
? true
: false;
type _MediaCoreExternalsAssert = _IocExpect<_MediaCoreExternalsSatisfied>;
type _InfraExternalsSatisfied =
InfraExternals extends Pick<AppCradle, keyof InfraExternals> ? true : false;
type _InfraExternalsAssert = _IocExpect<_InfraExternalsSatisfied>;The _IocExpect<T extends true> wrapper is required: a bare conditional that resolves to false does not fail tsc; assigning it to _IocExpect<false> does. When a composed package’s externals are not supplied by any member of AppCradle (typically the app’s local factories), compilation fails at the _…ExternalsAssert line. The developer fixes by adding a factory for the missing key or composing another manifest that supplies it.
App bootstrap imports composedManifests and AppCradle from this generated file. No hand-maintained list.
6.1 Required package exports
A package intended to be composed must expose two subpath exports in its package.json:
{
"exports": {
"./iocManifest": "./dist/generated/ioc-manifest.js",
"./iocTypes": "./dist/generated/ioc-registry.types.js",
},
}Path is conventional; the package may relocate via its own ioc.config (a manifestExportPath field, default ./generated/ioc-manifest). The library's documentation describes this convention; package authors add the exports manually as a one-time setup.
7. App-level overrides
Each package's manifest is a proposal, not a final registration. The app's ioc.config may override any contributing package's policy.
| App can override | How | | --------------------------------------- | ----------------------------------------------------------------------- | ------------- | | Default implementation across manifests | registrations: { Contract: { impl: { default: true } } } | | Lifetime of any registration | registrations: { Contract: { impl: { lifetime: 'singleton' } } } | | Registration key / access key | registrations: { Contract: { $contract: { accessKey: 'database' } } } | | Adding new implementations | App's own build* factories join the contract's implementation pool | | Same-key conflicts | registrations: { Contract: { impl: { source: 'local' | '@pkg' } } } |
Within a single package's ioc.config, the same registrations block continues to control that package's own factories. The app's ioc.config registrations block has authority over the composed set.
When a package declares policy for its own factories that conflicts with another package's policy (no app override), composition errors. The app's explicit override always wins; un-overridden cross-package conflicts must be resolved before composition succeeds.
7.1 Lifetime markers
Packages may declare categorical lifetimes via marker interfaces instead of (or alongside) per-implementation overrides or directory-scoped defaults.
defineIocConfig({
lifetimeMarkers: {
IScoped: "scoped",
ITransient: "transient",
// Singleton is the default; a marker is optional.
},
// ...
});Each key is the name of an interface or type alias visible in the package's TypeScript program at codegen time. Each value is singleton, scoped, or transient. An empty object {} is treated as "no markers declared" and skips marker analysis entirely.
For each discovered build* factory, codegen checks whether the factory's return type nominally declares heritage to each configured marker (via extends on interfaces or & on type-alias intersections — not structural shape matching). Transitive inheritance applies: if UserReadService extends IGroupedInterfaceA extends IScoped, a factory returning UserReadService matches IScoped.
Lifetime precedence (highest first):
registrations[Contract][implementation].lifetime— explicit per-implementation override- Lifetime marker on return type — from
lifetimeMarkers discovery.scanDirs[].scopefor the factory's containing scan root — directory-scoped default- Default:
singleton
If a factory matches zero markers, marker resolution does not apply (fall through). If it matches one marker, that marker's lifetime is used. If it matches more than one marker, codegen errors:
[ioc] Factory "buildRequestTracingLogger" at src/factories/buildRequestTracingLogger.ts:3 has multiple lifetime markers in its return type:
- "IScoped" → scoped
- "ITransient" → transient
Lifetime is ambiguous. Either remove one marker from the type's inheritance chain, or set lifetime explicitly via registrations.<Contract>.<impl>.lifetime in your ioc.config.ts.(The factory name in the message is the full export name, e.g. buildRequestTracingLogger.)
Library vs app mode. Markers work in both modes. Each package resolves markers against its own factories during its own ioc generate run. Lifetimes are baked into the emitted manifest; a composing app does not re-run marker resolution on library factories. A library author's marker convention therefore propagates to consumers via lifetimes in the manifest, not via the consumer's lifetimeMarkers config.
Interaction with groups. A marker interface can sit on a group base type (DiscountStrategy extends IScoped) so every group member inherits scoped lifetime without per-factory configuration. This subsumes a separate "group-level lifetime" feature.
Marker visibility. Marker types must be declared in source files that TypeScript includes when building the discovery program (typically the same package's src/). Imported-only types from another package's compiled output may not resolve unless that source is part of the program.
Empty marker interfaces. A marker declared as interface IScoped {} is valid. Membership requires an explicit extends IScoped (or transitive extends chain, or type Foo = Bar & IScoped) on the factory return type — not structural assignability of {}.
7.2 Nominal vs structural matching
Groups (§8) and lifetime markers (§7.1) use nominal membership: a type belongs to a base or marker only when its declaration says so via extends or type-alias & intersection. Structural assignability (isTypeAssignableTo) is still used elsewhere (contract return-type validation, deps-property emission) but not for group or marker membership.
Users upgrading from releases that required branded marker properties can remove those brands; extends IScoped alone is sufficient. Branded markers continue to work if left in place.
8. Groups across manifests
Group declarations are additive across manifests. A group named discountStrategies declared in any composed manifest collects all assignable implementations across the entire composed set.
8.1 Base type identity
Two packages declaring a group on the same base type must reference the same canonical source for that type. Codegen records each manifest's resolved source for the base type as an opaque identifier (<absolute-declaration-path>:<TypeName>, e.g. /…/packages/contracts/src/DiscountStrategy.ts:DiscountStrategy). At composition, the library matches base types by this canonical identifier.
Two manifests declaring a group with the same name but different canonical base types → composition error.
This forces a real architectural discipline: a base type intended as a group's collection point lives in a single canonical location (a shared contracts package or the package that owns the abstraction). Packages contributing implementations import the base type from that canonical location.
8.2 Group declaration
Each contributing package declares the group in its own ioc.config:
groups: {
discountStrategies: {
kind: 'collection',
baseType: 'DiscountStrategy',
},
}Codegen runs nominal membership (declared extends / intersection heritage, same rules as §7.2) against the package's local implementations only and records the group definition plus matching local impls in the manifest. An empty local membership emits [ioc-warn] but does not fail codegen — implementations may live in other composed packages.
At composition, group definitions merge by name. Same name + same canonical base type → contributions union. Same name + different canonical base type → error. The merged group is registered in the container; container.resolve('discountStrategies') returns the union.
8.3 Object group key collisions
An object group across manifests may produce duplicate keys (two packages both registering userReadService under readServices). This is a same-key conflict, resolved by the same source mechanism as §5.2.
8.4 Ordering
Collection groups are ReadonlyArray<T> in whatever order composition emits them. Consumers needing specific order put ordering metadata on the base type and sort at use time. The library does not order group members.
8.5 Grouped ⇒ group-only
A contract that is a member of a configured group is consumed through the group and through nothing else. This is the symmetric twin of "scope-rooted ⇒ opener-only", and it says the same thing: a contract has exactly one sanctioned way in.
Concretely, a grouped contract has:
- no contract-slot key. Not "unelected" — categorically slotless. The slot machinery exempts grouped contracts entirely, including a grouped contract with exactly one implementation, which is otherwise the slot's main road.
- no individual cradle keys for its implementations. A record group already gives typed per-member access through the group value (
channels.emailChannel); a collection group's members are individually anonymous by declaration — that is what choosingkind: "collection"says about them. - no default election, and therefore no default ambiguity. Several implementations with no
default: trueis the ordinary, correct shape of a group. The election is vacated by construction rather than passed: there is no slot for a default to fill, so the question is not asked.ioc validate'sdefault-ambiguitycheck skips grouped contracts for the same reason.
Membership is decided by config.groups base types and by nothing else — see §8.6.
Runtime keeps what the typed surface hides. Member registration keys stay registered on the container: the group resolver hands its members out by registration key, so it must be able to reach them. What changes is what a consumer may legally name. The contract-slot alias, by contrast, is not registered at all — runtime mirrors generation there, so nothing resolves under a name the emitted cradle does not carry.
Demanding a grouped member individually is a hard error through every spelling — key: Named<MemberContract>, key: Named<GroupBase>, the bare key: MemberContract, and the grouped contract's would-be contract key. All four are recognized as the same mistake and get the same guidance, because the problem is the family rather than which contract was named. See Error handling for the codes.
One consequence worth stating: the per-implementation membership filter that used to drop a non-default implementation registered at the contract's default-slot key is retired. Its whole justification was avoiding duplicate default-slot semantics inside the group; with no slot there is nothing to duplicate, and dropping a member would produce a group that looks complete and is not.
8.6 Group lifetime is declared on the base, only
A group is a family, and its members are handed out interchangeably — a collection group anonymously, a record group by contract name. A family whose members disagree about lifetime is not interchangeable at all: resolving the group would hand back a mixed array of singletons and per-scope instances, and the consumer has no way to know or care which is which. So the lifetime is declared once, on the base, and every member ranks it.
// contracts/LoggingService.ts — the group's base, and the only place its lifetime is declared.
export interface LoggingService extends IScoped {
readonly id: string;
ping: () => string;
}Every member reports lifetimeSource: "group-base-marker" — distinguished from an ordinary lifetime-marker because the declaration sits somewhere the member does not control, which is exactly what a reader chasing an unexpected lifetime needs to be told. ioc inspect --discovery prints it as scoped (group-base-marker).
Lifetime-inversion checking sees this through the group hop: a root-resolved singleton consuming a scoped group is the same defect as one consuming a scoped member, and is reported as via group '<key>'. Suppression is unchanged and belongs on the consuming registration (allowLifetimeInversion), not on the family.
A member declaring its own lifetime is a hard error — a lifetimeMarkers interface on the member contract's own heritage that the base does not carry, or a per-implementation lifetime override in ioc.config for a grouped member. This is not a conflict to resolve by precedence; it is a statement the member is not entitled to make, so it is refused rather than outranked. The test is a comparison, not a path trace: transitive heritage means a member extending a marked base also carries the marker, and the only thing distinguishing a member declaring one is the base not having it. A member that redundantly restates the base's own marker is indistinguishable from one that merely inherits it, and is treated as inheriting — the base owns the lifetime either way.
Tombstone: the 2.x group-lifetime rule
The rule this replaces was not most-restrictive-member-wins. Most-restrictive-wins does not appear anywhere in this codebase's history — not in groups, not in the bundles module that preceded it, not in the shipped docs. What 2.0.0 actually shipped is recorded in the v3 audit as item 22: "group roots are transient wrappers; members keep their own lifetimes." No aggregation at all — each member ranked independently, and a group could hand out a mixed-lifetime collection with nothing said about it.
Most-restrictive-wins is worth naming anyway, because it is the rule a tool reaches for when groups are undeclared: with no place to say what a family's lifetime is, the only way to avoid handing a singleton a per-scope instance is to infer one from the members and take the shortest. That is inference compensating for a missing declaration. Declaring the lifetime on the base removes the question instead of answering it, and supersedes both the inference that was never built and the independent-members rule that was.
8.7 Consumer-divergent group consumption — considered, deferred
The scenario is real and it will come up. Consumer A wants the family — "notify every channel" — and consumer B wants one member of it — "send this on the email channel specifically." Under §8.5, B cannot have what it wants: the member has no key, and the group's kind is the only lever over how members are exposed.
The reason the lever is coarse is that the group is declared in the supplier's config. A library declares groups.notificationChannels with a kind, and every app that composes that library inherits both the grouping and the kind. So B's need is not a local decision B can make; it is a request to re-shape a supplier's declaration from the consumer side, and there is nothing in the model today that lets a consumer do that for anything.
Supplier-config wins today, for two reasons:
- The supplier owns the abstraction. Grouping a family is a statement about what those contracts are — interchangeable members of one abstraction — and that is the supplier's call, in the same way the base type is. A consumer that reaches past it for one member is asserting the members are not interchangeable after all, which is a disagreement about the design, not about wiring.
- A consumer-side override would have to be checked against every other consumer. Group membership decides the emitted cradle of the supplier's package. Re-kinding or ungrouping from an app would mean the same library presents different cradles in different apps — which is exactly the per-package-manifest boundary this design exists to keep (§1.2), and it would make a library's own
IocExternals/IocGeneratedCradlepair depend on who composed it.
The shape of the future knob, when a real case arrives, is consumer-side re-kinding of composed members: an app-level declaration that a named composed group is exposed to this app under a different kind, or that a named member is additionally exposed under its own key in this app's cradle. It would be additive to the app's composed cradle rather than a mutation of the library's, which is what keeps the boundary intact — the library still emits what it emits, and the app declares a widening it is responsible for. The open questions are whether the widening is per-group or per-member, how it interacts with two apps composing the same library differently (it should not need to interact at all, if the widening is app-local), and whether the re-exposed key participates in the app's own key-uniqueness namespace (it must).
None of that is designed. It is deferred deliberately, not overlooked, and the grouped-member error points here so a developer who hits the wall knows which it is. Until then the honest answers are: consume the family and filter at use time, use kind: "object" so members are exposed as properties, or take the member out of the group — it was never a member of that family if one consumer needs it by name.
9. CLI
9.1 ioc generate
Behavior branches on the presence of composedManifests in the config:
- Library mode (no
composedManifests): emitsioc-manifest.tsandioc-registry.types.tsonly. Manifest is intended for export. - App mode (with
composedManifests): emits all three files, includingioc-composed.ts, and runs the full composition suite (§9.2) before writing any of them.
No separate command for the two modes. The config's shape determines emission.
9.2 The composition suite: generate enforces it, validate re-runs it
Everything gen can know, gen enforces; validate exists to run the same checks without regenerating. An app package's generate already composes — it loads composed manifests, emits ioc-composed.ts, walks composed subtrees and resolves composed opener and slot keys — so it already holds every fact the compositional checks adjudicate. Leaving those checks in a separate verb meant that for the primary workflow, where generated output is not checked in and gen runs on every change, the verb structurally never ran and the entire checking layer was dead code: gen passed while validation had all manner of errors. Both verbs now call one module over one program construction, so they cannot disagree; validate's remaining reason to exist is that it does not regenerate, which is what a CI gate over committed artifacts and a check against a rebuilt dependency both need.
The checks, unchanged in substance:
- Every key in every
IocExternalshas a supplier in the composed set, with the types compared. - No same-key conflicts unresolved by
sourceconfig. - All groups have consistent canonical base types across contributors.
- Default selection unambiguous for every ungrouped contract, including app-config overrides (§8.5).
- The
registry-integritygate: a generated registry file that does not compile is reported, and the comparisons that read types out of it are skipped and said to be skipped, never adjudicated against error types.
Output is structured per issue: package + key + suggested fix, every offender in one aggregated report. In generate an error-severity finding aborts the run before any file is written; in validate it is a non-zero exit. Warnings warn in both.
Both build the same program — the app's own tsconfig.json, the app's full source rooted, resolution as the app's own tsc performs it — and admit each physical file exactly once. Library mode runs none of it: every check is a relation between manifests, and a library has no composed set to relate to.
9.3 ioc inspect
Unchanged behavior. Remains package-local. Loads the local manifest and prints a summary.
9.4 Flags
Existing flags (--config, -c, --project) carry over. IOC_DEBUG=1 env var continues to enable full stack traces.
10. Testing patterns
The deps-type-at-factory-site pattern enables three test levels, each with the right ergonomics.
10.1 Factory-level (no container)
import { buildValidateOperationService } from "../src/...";
import type { ValidateOperationServiceDeps } from "../src/...";
const deps: ValidateOperationServiceDeps = {
/* stubs */
};
const svc = buildValidateOperationService(deps);No container, no manifest. TypeScript directly enforces what must be provided.
10.2 Container-level with mocked externals
import { createContainer, asValue } from "awilix";
import { registerIocFromManifest } from "ioc-manifest";
import { iocManifest } from "../src/generated/ioc-manifest.js";
import type {
IocGeneratedCradle,
IocExternals,
} from "../src/generated/ioc-registry.types.js";
const container = createContainer<IocGeneratedCradle>();
registerIocFromManifest(container, [iocManifest]);
const externals: IocExternals = {
database: mockKnex,
logger: silentLogger,
};
for (const [k, v] of Object.entries(externals)) {
container.register({ [k]: asValue(v) });
}The package's manifest handles internally-supplied factories. The IocExternals type makes the external surface a typed checklist: forget one and TS errors; add a new external dep in the package and every test breaks until updated.
10.3 Test-specific manifest
For shared stubs across many tests, write stub factories under tests/stubs/ and a separate ioc.config.test.ts scanning both src and tests/stubs. Generate a test manifest. Use as in §10.2.
10.4 Runtime safety helper
The library exposes:
function getUnregisteredKeys(
container: AwilixContainer,
manifests: readonly IocManifest[],
): string[];Returns the set of demanded keys not satisfied after registration. Useful in test setup to assert all externals are mocked before the suite runs.
11. Build orchestration
In a task-graph monorepo (nx or similar):
- Each package's
buildtarget depends onioc generatefor that package. - Each package's
ioc generatedepends on its own source. - The app's
ioc generatedepends on the manifest export of every package incomposedManifestsbeing available (so the package subpath imports resolve). ioc validate(CI) depends on every composed package being built.
This works cleanly with nx's standard dependsOn: ['^build'] pattern. No library mechanism required; documented in the README.
12. Configuration schema
The ioc.config.ts schema changes as follows.
12.1 Added
| Field | Type | Purpose |
|---|---|---|
composedManifests | string[] | Package names whose manifests this app composes. Triggers app-mode codegen. |
manifestExportPath | string | Optional. Path the manifest is exported from in the package's package.json exports. Default ./generated/ioc-manifest. Library-mode only. |
registrations[Contract][impl].source | 'local' | string | Resolves same-key conflicts at composition. |
groupBaseTypeAliases | Record<string, string[]> | Optional. Declares equivalence sets of canonical base-type identifiers, for resolving diamond-dep mismatches (§14.4.1). App-mode only. |
lifetimeMarkers | Record<string, IocLifetime> | Optional. Maps interface/type-alias names to lifetimes; factories whose return type is assignable to a marker inherit that lifetime (§7.1). Library and app mode. |
12.2 Removed
| Field | Reason |
|---|---|
discovery.scanDirs[].importPrefix | Cross-package scanning removed. |
discovery.scanDirs[].importMode | Cross-package scanning removed. |
discovery.workspacePackageImportBases | Cross-package scanning removed. |
discovery.scanDirs entries pointing outside the package root | Codegen errors on paths that escape the package boundary. |
12.3 Unchanged
scanDirs (within-package paths only), includes, excludes, factoryPrefix, generatedDir, registrations (other fields), groups.
13. Breaking changes summary
The next major version is a hard cut. No backward compatibility.
- Cross-package
scanDirsremoved. Packages must generate their own manifests. importPrefix,importMode,workspacePackageImportBasesremoved from config. Replaced by package subpath exports.- Factories must declare named deps types.
IocGeneratedCradledestructure disallowed at factory sites. Codegen errors with the factory location and a documentation pointer. - App configs must declare
composedManifeststo consume cross-package factories. Apps using only their own local factories are unaffected by this specific change. - Apps must register subpath exports for
./iocManifestand./iocTypesin any package intended to be composed.
No migration tooling. Migration is documented but performed by hand. Justification: current usage is single-digit downloads, predominantly the author's own repo.
14. Open questions surfaced during design
These were identified but deferred. They do not block initial implementation.
14.1 Optional externals
A package may want to declare cache?: Cache — an external it can function without. The composition assertion would need to treat optional keys specially (Pick<AppCradle, ...> allows missing optional keys). Implementation is straightforward but unspecified. Defer until a real use case appears.
14.2 Manifest versioning (resolved: ship on day one)
A package may evolve its manifest's shape over time. If a host composes a manifest from a package built against an older ioc-manifest version, behavior is undefined.
Day-one decision: every emitted manifest carries a manifestSchemaVersion field (currently 2; group roots use the IocGroupRootManifest wrapper). registerIocFromManifest reads this field and refuses composition if any manifest declares an incompatible version, with a clear error naming the manifest and its version. The runtime check is cheap; the cost of not including the field from day one (an impossible-without-breaking-change addition later) is unacceptable.
Compatibility policy is forward-only for the same major version: a v1 runtime accepts v1 manifests, refuses v2. A v2 runtime accepts both v1 and v2 unless v1 is explicitly deprecated. Cross-major composition requires the host to upgrade.
Added to §15 acceptance criteria.
14.3 Manifest tree-shaking
The composed app's bundle includes every factory from every composed manifest. A large media-core whose factories aren't all consumed by the app produces dead code. Bundlers can usually eliminate unreachable factories, but the static-imports-pinned-by-manifest pattern may inhibit this. Worth benchmarking with a real bundle before declaring it solved.
14.4 Diamond dependencies
If two packages in composedManifests both depend (as normal npm deps) on the same shared types package, npm/pnpm hoisting usually produces a single physical location and the canonical-base-type identifier match in §8.1 succeeds. When hoisting fails (version skew, peer-dep conflicts, nested installs), each package resolves the shared type to a different physical path, producing different canonical identifiers. The identifier check then fails on structurally identical types.
This is a known hazard. The mitigation plan, in order of escalation:
14.4.1 Manual base-type aliases (ship on day one).
The app's ioc.config may declare equivalences:
groupBaseTypeAliases: {
DiscountStrategy: [
'@packages/contracts#DiscountStrategy',
'@packages/media-core/node_modules/@packages/contracts#DiscountStrategy',
],
}At composition, the library matches canonical identifiers against the alias sets. If two contributing manifests reference any pair of identifiers in the same alias set, they are treated as the same base type. Implementation cost is small; documentation cost is real (the README must explain the symptom and the fix). This is the band-aid users reach for the day they hit the problem.
Added to §12.1 as a new optional config field.
14.4.2 Structural fallback at validate time (ship if 14.4.1 proves too manual).
When identifier match fails between two contributors to the same group name, ioc validate runs a structural comparison of the base types using a TS program over the composed manifests' type files. If the types are structurally identical, validate emits an actionable warning and suggests the exact groupBaseTypeAliases entry the user should add. This keeps runtime composition cheap (no structural comparison at registerIocFromManifest time) while removing the manual diagnosis step.
Trigger for implementing this: more than one user (or one repeated incident) hitting 14.4.1's manual diagnosis flow. Not shipped speculatively.
14.4.3 Explicit baseTypeId per group (escape hatch of last resort).
If structural fallback proves insufficient (e.g. the structural check produces false matches between distinct types that happen to share a shape), the group declaration accepts an optional explicit identifier:
groups: {
discountStrategies: {
kind: 'collection',
baseType: 'DiscountStrategy',
baseTypeId: 'discount-strategy',
},
}Groups with baseTypeId set bypass canonical-path matching entirely and identify by the string. Contributors must coordinate the string manually. Worse default than canonical-path matching for the common case, so not made required; available per-group as an opt-in override.
This option is not shipped on day one. It is documented internally (here) as a planned escape hatch so the design space is preserved; the implementation is straightforward to add when needed.
Decision rule: if a user reports 14.4.1's pain twice independently, implement 14.4.2 in the next minor. If 14.4.2 proves insufficient, implement 14.4.3. Do not preemptively ship the more complex mechanisms.
14.5 Circular composition
Package A composes manifest from B, B composes from A. Conceptually nonsense — packages compose downward in the dependency graph — but the library should detect and reject this at validation time rather than allowing weird behavior.
14.6 Directory-scoped lifetimes (legacy pattern)
discovery.scanDirs[].scope remains supported but is considered a legacy pattern for expressing lifetime policy. It works when implementations are co-located by lifetime category (src/services/, src/repos/). Domain-organized codebases (src/users/, src/orders/) fit poorly. lifetimeMarkers (§7.1) is the recommended pattern for cross-cutting categorical lifetime policy. Directory scope is not deprecated; use it when directory layout genuinely mirrors lifetime boundaries.
15. Acceptance criteria
The implementation is considered complete when:
- A package can declare
ioc.config.tsscanning only its own source, runioc generate, and produce a working manifest, types file, and externals interface. - An app can declare
composedManifests, runioc generate, and produce a workingioc-composed.tswith a satisfaction-assertedAppCradle(_IocExpectassertions fail compilation when composed externals are missing from the intersection). registerIocFromManifest(container, manifests)composes any number of manifests, resolves cross-manifest contracts per §5, and refuses unresolved conflicts.- Same-key conflicts produce errors with both manifest sources named, resolvable via
sourceconfig. - Cross-manifest groups merge per §8 with canonical-base-type matching.
ioc validateexits non-zero on any composition issue and prints actionable per-issue output.- The consuming monorepo currently triggering §1.1 builds without manual
dist/cleanup. - Test patterns from §10 all work as documented in the new test suite.
- Codegen errors on factories using
IocGeneratedCradledirectly, on cross-packagescanDirs, and on demanded keys with type disagreement across factories. - Documentation (README and a new design doc / advanced-usage section) reflects the new model.
- Every emitted manifest carries a
manifestSchemaVersion: 1field, andregisterIocFromManifestrefuses incompatible manifests with a clear error. - App-mode
ioc.configacceptsgroupBaseTypeAliasesand composition matches base-type identifiers against the alias sets.