Skip to content
GitHub

Architecture


lexigram is the core package — the foundation that every extension package builds on. It provides the DI container, application lifecycle, provider pattern, configuration system, and the Result type.

Extension packages (lexigram-web, lexigram-sql, …)
↑ depends on
┌────┴────┐
│ lexigram │ ← DI container, Application, Provider, Module, Config, Result
└────┬────┘
↑ depends on
┌────┴────────┐
│ lexigram-contracts │ ← Protocols, types, exceptions (zero deps)
└─────────────────┘

┌────────────────────────────────────────────────────┐
│ Application │
│ ┌──────────┐ ┌────────────────┐ ┌────────────┐ │
│ │Container │ │ProviderOrch. │ │ Invoker │ │
│ │ │ │ │ │ │ │
│ │ register │ │ sort by │ │ DI function│ │
│ │ resolve │ │ priority+deps │ │ calling │ │
│ │ validate │ │ boot/shutdown │ │ │ │
│ └────┬─────┘ └────────────────┘ └────────────┘ │
│ │ │
│ ┌────┴──────────────────────────────────────┐ │
│ │ MiddlewarePipeline │ │
│ └───────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ LexigramConfig │ │
│ │ app_name, debug, env, logging, modules │ │
│ └──────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘

The IoC container is a facade coordinating:

Sub-componentResponsibility
ServiceRegistryStores bindings (service type → descriptor)
ServiceResolverResolves typed dependencies recursively
DependencyInjectorCreates instances with constructor injection
ContainerRegistrarImplWrite operations (singleton, transient, scoped)
ContainerResolverImplRead operations (resolve, resolve_optional, resolve_all)
ContainerValidatorPre-flight validation (missing deps, circular refs, scope violations)
FunctionInvokerAuto-injects arguments when calling functions
TypeHintResolverImplResolves type hints with caching

Service scopes:

ScopeLifetimeRegistration Method
SINGLETONOne instance for the container lifetimecontainer.singleton(T, instance) or container.singleton(T, factory=factory)
TRANSIENTNew instance per resolutioncontainer.transient(T, factory)
SCOPEDOne instance per scope contextcontainer.scoped(T, factory)

Lifecycle:

Register ──► Freeze ──► Resolve ──► Dispose
validate()

The Provider base class defines the lifecycle contract:

FieldTypeDefaultDescription
namestr"" (auto-derived from class name)Unique identifier
priorityProviderPriorityNORMALBoot ordering
dependenciestuple[str, ...]()Named provider dependencies
requiredboolTrueFatal if boot fails
config_keystr | NoneNoneConfig section to inject
config_modeltype | NoneNoneConfig model class

Extension point: Subclass Provider and override lifecycle hooks. Providers are discovered via app.discover_providers() or added explicitly via app.add_provider().

Modules provide encapsulation over groups of providers:

ComponentPurpose
@module decoratorAttaches ModuleMetadata to a class
Module base classClassVar defaults, configure(), scope(), stub() factory methods
DynamicModuleRuntime-created module descriptor
ModuleCompilerCompiles the module graph into an ordered provider list
ModuleMetadataStores providers, imports, exports, scan paths

Visibility rules:

  • Services in a module are private by default
  • Only types listed in exports are resolvable by importing modules
  • @global_module exports are visible to all modules without imports

Re-exports from lexigram.contracts.core.result. The Result[T, E] type is a discriminated union:

class Result(Generic[T, E]):
def is_ok(self) -> bool: ...
def is_err(self) -> bool: ...
def unwrap(self) -> T: ... # raises UnwrapError on Err
def unwrap_err(self) -> E: ...
def unwrap_or(self, default: T) -> T: ...
def map(self, fn) -> Result[U, E]: ... # async
def map_sync(self, fn) -> Result[U, E]: ... # sync
def and_then(self, fn) -> Result[U, E]: ... # async
def and_then_sync(self, fn) -> Result[U, E]: ...
def match(self, *, ok, err) -> U: ...
def inspect(self, fn) -> Self: ...
def inspect_err(self, fn) -> Self: ...
def flatten(self) -> Result[T, E]: ...
@staticmethod
def from_exception(exc) -> Result[Never, Exception]: ...

Configuration loading uses a layered source model:

Sources (ordered by priority, later wins):
1. Class defaults (LexigramConfig fields)
2. YAML file (application.yaml)
3. Additional YAML/files via ConfigLoader
4. Environment variables (LEX_<KEY>)
5. .env file
6. CLI options
LexigramConfig (Pydantic/BaseConfig model)
Container singleton (LexigramConfig + ConfigProtocol)

ConfigProvider (priority CRITICAL) loads config during register() — this is the one intentional exception to the rule that providers shouldn’t do I/O in register().


Core providers (included with lexigram):

ProviderNamePriorityRegisters
ConfigProvider"config"CRITICALLexigramConfig, ConfigProtocol
CoreProvider"core"CRITICALCore infrastructure services
CoreInfrastructureProvider"core_infrastructure"CRITICALAmbient primitives (clock, identity, hashing)
LoggingProvider"logging"CRITICALLoggerProtocol, structlog config
IdentityProvider"identity"INFRASTRUCTUREID generation
ConfigProvider"config"CRITICALConfiguration loading
MiddlewareProvider"middleware"INFRASTRUCTUREMiddleware chain
DiProvider"di"INFRASTRUCTUREDI system extensions

lexigram depends on protocols from lexigram-contracts:

ProtocolImportUsed By
ContainerRegistrarProtocollexigram.contracts.core.diProvider.register() parameter
ContainerResolverProtocollexigram.contracts.core.diResolution API
BootContainerProtocollexigram.contracts.core.diProvider.boot() parameter
ConfigProtocollexigram.contracts.core.configLexigramConfig implements this
ProviderPrioritylexigram.contracts.core.providerProvider.priority field
HealthCheckResultlexigram.contracts.core.healthProvider.health_check() return
ClockProtocollexigram.contracts.core.clockAmbient clock capability
IdGeneratorProtocollexigram.contracts.core.identityIdentity generation
LoggerProtocol(lexigram.logging)Structured logging

sequenceDiagram
    participant App as Application
    participant Comp as ModuleCompiler
    participant PC as ProviderOrchestrator
    participant C as Container
    participant P as Provider

    App->>C: create
    App->>PC: add providers
    App->>App: start()
    App->>Comp: compile() if modules
    App->>PC: register_all()
    loop for each provider
        PC->>P: register(C)
        P->>C: singleton(...) / transient(...) / scoped(...)
    end
    PC->>C: freeze(validate=True)
    PC->>C: validate() if enabled
    loop boot levels (topological order)
        PC->>P: boot(C)
        P->>C: resolve(...)
    end
    App-->>App: RUNNING
    Note over App: Application running
    App->>App: stop()
    loop reverse priority
        PC->>P: shutdown()
    end
    App->>C: dispose()
    App-->>App: STOPPED

If any provider’s boot() raises, all previously booted providers are shut down in reverse order, and the application transitions to STOPPED. The on_error(error, phase) hook is called for each failed provider.

The MiddlewarePipeline wraps function calls via the Invoker. Middleware is ordered and applied around every invoker.invoke() call.


ExtensionHowExample
Custom ProviderSubclass ProviderCreate a provider for your domain services
Custom Module@module() decoratorEncapsulate a vertical slice
Config SourceImplement ConfigSourceProtocolLoad from Vault, SSM, custom backend
Resolution StrategyImplement ResolutionStrategyCustom annotation-based injection
InterceptorRegister on InterceptorRegistryAOP around service resolution
Service Registrationcontainer.singleton()Bind any protocol to an implementation
MiddlewareImplement MiddlewareProtocolLogging, tracing, auth around function calls