Internal State: Not Evil, Just Misplaced
State is everywhere. And that’s not a problem — unless you pretend it isn’t. In real-world systems, especially those that span multiple scopes like backend applications, state is inevitable. The key isn’t to eliminate it — it’s to manage it deliberately. While building a new dependency injection library for TypeScript called TypeWire, I applied the same architectural principles I’ve long followed — particularly around clarity in boundaries and ownership of state. This article shares lessons learned from that experience, using the internal evolution of TypeWire to illustrate how state can be structured, isolated, and owned with confidence — without becoming ambient or unpredictable. Internal State in Practice I’ve been crafting a project called TypeWire, a DI library for TypeScript, and along the way I’ve learned a lot about designing internal state with strong boundaries. TypeWire started with a simple container class that handled everything — bindings, singleton caches, and resolution state — all internally. class TypeWireContainer implements ResolutionContext, BindingContext { private readonly bindings = new Map(); private readonly singletons = new Map(); private readonly resolutions = new Map(); async get(typeSymbol: TypeSymbol): Promise { // Resolution logic lived here } } This worked until I began hitting concurrency issues during parallel resolutions, particularly false positives in the circular dependency monitor. This pushed me toward isolating resolution per request. To fix that, I created a ScopedResolutionContext — an ephemeral context that owns each resolution lifecycle: class ScopedResolutionContext implements ResolutionContext { constructor( private readonly bindings: Map, private readonly singletons: Map, private readonly resolutionMonitor: ResolutionMonitor, ) {} } This made things easier to reason about and brought clear ownership to resolution-time behavior. But resolution-specific mutable state still lived in this class. So I extracted that too — into ResolutionState, whose sole job is to track in-flight data during resolution. class ResolutionState { readonly resolutions = new Map(); } This gave me three clear layers: TypeWireContainer: orchestrates the lifecycle ScopedResolutionContext: owns per-resolution behavior ResolutionState: holds mutable state, tightly scoped and invisible elsewhere These decisions were made iteratively — each step clarified boundaries and reduced ambiguity State can be refined gradually Ownership boundaries can evolve toward clarity Internal structure matters as much as public API A well-abstracted public API makes this evolution possible — since behavior remains stable while internals improve Scoped State and Behavioral Boundaries Short-lived or request-bound state can be tricky to manage — especially when it spreads into shared objects or global modules. In my own work, I try to construct state on demand and ensure it’s owned by a tightly scoped boundary. In the same system, transient resolution data — like in-flight dependency graphs — is encapsulated inside a ResolutionState class. This class lives only during resolution, and is owned by a ScopedResolutionContext wrapper: class ScopedResolutionContext { constructor(private readonly resolutionState: ResolutionState, ...) {} } This pattern reflects a broader principle: Internal state isn't just hidden — it's delegated further to the layer that truly owns its lifecycle Mutability is minimized and localized Behavior is centralized around one interface When I work with contextual and temporary state, I try to give it a dedicated layer — and let behavior encapsulate its usage. Long-Lived vs Contextual State Some state lives for the lifecycle of the application (e.g., configuration caches, connection pools). Others live per request, per resolution, or per session. Issues can emerge when long-lived objects inadvertently hold onto short-lived context. In our example, singleton values are stored only once and safely. Meanwhile, contextual logic — like what’s needed during one resolution flow — is dynamically built per call and discarded afterward. The structure makes ownership obvious: Global state lives in a global container Resolution-specific state lives inside ResolutionState Nothing is ambient, and nothing crosses boundaries without intent Preventing Scope Bleed Architecture fails when context-sensitive state (like user identity or feature flags) leaks into shared objects. Even with a container, this can happen: A singleton accesses a value that changes every request Transient logic is accidentally shared What helps in these situations isn't a tool — it’s careful structure: Pass context explicitly Use factory functions for delayed evaluation Avoid injecting context into globally-shared constructs The container pattern can help, but only if the boundaries

State is everywhere. And that’s not a problem — unless you pretend it isn’t.
In real-world systems, especially those that span multiple scopes like backend applications, state is inevitable. The key isn’t to eliminate it — it’s to manage it deliberately.
While building a new dependency injection library for TypeScript called TypeWire, I applied the same architectural principles I’ve long followed — particularly around clarity in boundaries and ownership of state.
This article shares lessons learned from that experience, using the internal evolution of TypeWire to illustrate how state can be structured, isolated, and owned with confidence — without becoming ambient or unpredictable.
Internal State in Practice
I’ve been crafting a project called TypeWire, a DI library for TypeScript, and along the way I’ve learned a lot about designing internal state with strong boundaries.
TypeWire started with a simple container class that handled everything — bindings, singleton caches, and resolution state — all internally.
class TypeWireContainer implements ResolutionContext, BindingContext {
private readonly bindings = new Map<symbol, TypeWire<unknown>>();
private readonly singletons = new Map<symbol, unknown>();
private readonly resolutions = new Map<symbol, Promise<unknown>>();
async get<T>(typeSymbol: TypeSymbol<T>): Promise<T> {
// Resolution logic lived here
}
}
This worked until I began hitting concurrency issues during parallel resolutions, particularly false positives in the circular dependency monitor. This pushed me toward isolating resolution per request.
To fix that, I created a ScopedResolutionContext
— an ephemeral context that owns each resolution lifecycle:
class ScopedResolutionContext implements ResolutionContext {
constructor(
private readonly bindings: Map<symbol, TypeWire<unknown>>,
private readonly singletons: Map<symbol, unknown>,
private readonly resolutionMonitor: ResolutionMonitor,
) {}
}
This made things easier to reason about and brought clear ownership to resolution-time behavior. But resolution-specific mutable state still lived in this class.
So I extracted that too — into ResolutionState
, whose sole job is to track in-flight data during resolution.
class ResolutionState {
readonly resolutions = new Map<symbol, Promise<unknown>>();
}
This gave me three clear layers:
-
TypeWireContainer
: orchestrates the lifecycle -
ScopedResolutionContext
: owns per-resolution behavior ResolutionState
: holds mutable state, tightly scoped and invisible elsewhereThese decisions were made iteratively — each step clarified boundaries and reduced ambiguity
State can be refined gradually
Ownership boundaries can evolve toward clarity
Internal structure matters as much as public API
A well-abstracted public API makes this evolution possible — since behavior remains stable while internals improve
Scoped State and Behavioral Boundaries
Short-lived or request-bound state can be tricky to manage — especially when it spreads into shared objects or global modules.
In my own work, I try to construct state on demand and ensure it’s owned by a tightly scoped boundary.
In the same system, transient resolution data — like in-flight dependency graphs — is encapsulated inside a ResolutionState
class. This class lives only during resolution, and is owned by a ScopedResolutionContext
wrapper:
class ScopedResolutionContext {
constructor(private readonly resolutionState: ResolutionState, ...) {}
}
This pattern reflects a broader principle:
- Internal state isn't just hidden — it's delegated further to the layer that truly owns its lifecycle
- Mutability is minimized and localized
- Behavior is centralized around one interface
When I work with contextual and temporary state, I try to give it a dedicated layer — and let behavior encapsulate its usage.
Long-Lived vs Contextual State
Some state lives for the lifecycle of the application (e.g., configuration caches, connection pools). Others live per request, per resolution, or per session.
Issues can emerge when long-lived objects inadvertently hold onto short-lived context.
In our example, singleton values are stored only once and safely. Meanwhile, contextual logic — like what’s needed during one resolution flow — is dynamically built per call and discarded afterward.
The structure makes ownership obvious:
- Global state lives in a global container
- Resolution-specific state lives inside
ResolutionState
- Nothing is ambient, and nothing crosses boundaries without intent
Preventing Scope Bleed
Architecture fails when context-sensitive state (like user identity or feature flags) leaks into shared objects.
Even with a container, this can happen:
- A singleton accesses a value that changes every request
- Transient logic is accidentally shared
What helps in these situations isn't a tool — it’s careful structure:
- Pass context explicitly
- Use factory functions for delayed evaluation
- Avoid injecting context into globally-shared constructs
The container pattern can help, but only if the boundaries are respected.
Encapsulation and Ownership
State is not the enemy. Unowned state is.
What makes the TypeWire container safe is not the lack of state — it’s the clarity of ownership:
- The container owns global state
- The resolution context owns transient state
- No one else touches either
In your own system:
- Choose who owns the cache
- Decide where request-scoped values live
- Expose behavior — not state
If a Map
or a flag is being read and written by multiple layers, I consider it no longer internal. That’s when I try to wrap it, abstract it, and assign clear ownership.
Reflection: Does This Cover It?
Let’s recap what good internal state management looks like — and whether our example matches up:
Principle | Reflected in TypeWire? |
---|---|
Scoped state is owned | ✅ ScopedResolutionContext + ResolutionState |
Long-lived state is isolated | ✅ Singletons live in the container only |
Nothing is ambient or implicit | ✅ No global context injection |
Access is through behavior | ✅ All state hidden behind get , bind
|
Mutable state is not shared | ✅ Encapsulated in internal maps |
Contextual resolution is explicit | ✅ Built per-call, never reused |
Even internal constructs like ResolutionState
are a reminder that internal state can benefit from layered ownership — and I’ve found that layering it often helps clarify intent.
Conclusion
State isn’t evil. But pretending your system doesn’t have any — or letting it sprawl uncontrolled — is.
In TypeWire, we embraced internal state:
- Singleton caches exist — but behind a behavioral interface
- Transient resolution is scoped — not ambient
- Contextual state is passed or constructed — never injected blindly
This wasn’t solved all at once. These boundaries — and the state ownership behind them — were clarified over time. Each change made the next one easier, precisely because earlier decisions respected separation of concerns.
When internal state is well-scoped and behaviorally owned, it enables iterative improvements without ripple effects.
The result is a system that uses state effectively, safely, and transparently.
And that’s the goal for any architecture: not to be stateless, but to be clear about where state lives, who owns it, and what it supports.