Changelog
- 2022-05-04: Initial Draft
- 2022-08-19: Updates
Status
PROPOSED ImplementedAbstract
In order to make it easier to build Cosmos SDK modules and apps, we propose a new app wiring system based on dependency injection and declarative app configurations to replace the currentapp.go
code.
Context
A number of factors have made the SDK and SDK apps in their current state hard to maintain. A symptom of the current state of complexity issimapp/app.go
which contains almost 100 lines of imports and is otherwise over 600 lines of mostly boilerplate code that is
generally copied to each new project. (Not to mention the additional boilerplate which gets copied in simapp/simd
.)
The large amount of boilerplate needed to bootstrap an app has made it hard to release independently versioned go
modules for Cosmos SDK modules as described in ADR 053: Go Module Refactoring.
In addition to being very verbose and repetitive, app.go
also exposes a large surface area for breaking changes
as most modules instantiate themselves with positional parameters which forces breaking changes anytime a new parameter
(even an optional one) is needed.
Several attempts were made to improve the current situation including ADR 033: Internal-Module Communication
and a proof-of-concept of a new SDK. The discussions around these
designs led to the current solution described here.
Decision
In order to improve the current situation, a new “app wiring” paradigm has been designed to replaceapp.go
which
involves:
- declaration configuration of the modules in an app which can be serialized to JSON or YAML
- a dependency-injection (DI) framework for instantiating apps from the that configuration
Dependency Injection
When examining the code inapp.go
most of the code simply instantiates modules with dependencies provided either
by the framework (such as store keys) or by other modules (such as keepers). It is generally pretty obvious given
the context what the correct dependencies actually should be, so dependency-injection is an obvious solution. Rather
than making developers manually resolve dependencies, a module will tell the DI container what dependency it needs
and the container will figure out how to provide it.
We explored several existing DI solutions in golang and felt that the reflection-based approach in uber/dig
was closest to what we needed but not quite there. Assessing what we needed for the SDK, we designed and built
the Cosmos SDK depinject module, which has the following
features:
- dependency resolution and provision through functional constructors, ex:
func(need SomeDep) (AnotherDep, error)
- dependency injection
In
andOut
structs which supportoptional
dependencies - grouped-dependencies (many-per-container) through the
ManyPerContainerType
tag interface - module-scoped dependencies via
ModuleKey
s (where each module gets a unique dependency) - one-per-module dependencies through the
OnePerModuleType
tag interface - sophisticated debugging information and container visualization via GraphViz
StoreKey
could be a module-scoped dependency which is unique per module- a module’s
AppModule
instance (or the equivalent) could be aOnePerModuleType
- CLI commands could be provided with
ManyPerContainerType
s
Declarative App Config
In order to compose modules into an app, a declarative app configuration will be used. This configuration is based off of protobuf and its basic structure is very simple:cosmos.bank.module.v1.Module
). Modules are given a unique short name
to share resources across different versions of the same module which might have a different protobuf package
versions (ex. cosmos.bank.module.v2.Module
). All module config objects should define the cosmos.app.v1alpha1.module
descriptor option which will provide additional useful metadata for the framework and which can also be indexed
in module registries.
An example app config in YAML might look like this:
baseapp
module which contains the information around ordering of
begin blockers, end blockers, and init genesis. Rather than lifting these concerns up to the module config layer,
they are themselves handled by a module which could allow a convenient way of swapping out different versions of
baseapp (for instance to target different versions of tendermint), without needing to change the rest of the config.
The baseapp
module would then provide to the server framework (which sort of sits outside the ABCI app) an instance
of abci.Application
.
In this model, an app is modules all the way down and the dependency injection/app config layer is very much
protocol-agnostic and can adapt to even major breaking changes at the protocol layer.
Module & Protobuf Registration
In order for the two components of dependency injection and declarative configuration to work together as described, we need a way for modules to actually register themselves and provide dependencies to the container. One additional complexity that needs to be handled at this layer is protobuf registry initialization. Recall that in both the current SDKcodec
and the proposed ADR 054: Protobuf Semver Compatible Codegen,
protobuf types need to be explicitly registered. Given that the app config itself is based on protobuf and
uses protobuf Any
types, protobuf registration needs to happen before the app config itself can be decoded. Because
we don’t know which protobuf Any
types will be needed a priori and modules themselves define those types, we need
to decode the app config in separate phases:
- parse app config JSON/YAML as raw JSON and collect required module type URLs (without doing proto JSON decoding)
- build a protobuf type registry based on file descriptors and types provided by each required module
- decode the app config as proto JSON using the protobuf type registry
internal
generated code which is not registered with the global protobuf registry, this code should provide
an alternate way to register protobuf types with a type registry. In the same way that .pb.go
files currently have a
var File_foo_proto protoreflect.FileDescriptor
for the file foo.proto
, generated code should have a new member
var Types_foo_proto TypeInfo
where TypeInfo
is an interface or struct with all the necessary info to register both
the protobuf generated types and file descriptor.
So a module must provide dependency injection providers and protobuf types, and takes as input its module
config object which uniquely identifies the module based on its type URL.
With this in mind, we define a global module register which allows module implementations to register themselves
with the following API:
*appmodule.Handler
type referenced above is a replacement for the legacy AppModule
framework, and
described in ADR 063: Core Module API.
New app.go
With this setup, app.go
might now look something like this:
Application to existing SDK modules
So far we have described a system which is largely agnostic to the specifics of the SDK such as store keys,AppModule
,
BaseApp
, etc. Improvements to these parts of the framework that integrate with the general app wiring framework
defined here are described in ADR 063: Core Module API.
Registration of Inter-Module Hooks
Registration of Inter-Module Hooks
Some modules define a hooks interface (ex.StakingHooks
) which allows one module to call back into another module
when certain events happen.
With the app wiring framework, these hooks interfaces can be defined as a OnePerModuleType
s and then the module
which consumes these hooks can collect these hooks as a map of module name to hook type (ex. map[string]FooHooks
). Ex:
- needing to expose all the keepers of all modules to the module providing hooks,
- not allowing for encapsulating hooks on a different type which doesn’t expose all keeper methods,
- harder to know statically which module expose hooks or are checking for them.
app.go
if depinject
codegen
(described below) is used.
Code Generation
Thedepinject
framework will optionally allow the app configuration and dependency injection wiring to be code
generated. This will allow:
- dependency injection wiring to be inspected as regular go code just like the existing
app.go
, - dependency injection to be opt-in with manual wiring 100% still possible.
Module Semantic Versioning
When we start creating semantically versioned SDK modules that are in standalone go modules, a state machine breaking change to a module should be handled as follows:- the semantic major version should be incremented, and
- a new semantically versioned module config protobuf type should be created.
github.com/cosmos/cosmos-sdk/x/bank
with the module config type
cosmos.bank.module.v1.Module
, and we want to make a state machine breaking change to the module, we would:
- create a new go module
github.com/cosmos/cosmos-sdk/x/bank/v2
, - with the module config protobuf type
cosmos.bank.module.v2.Module
.
cosmos.bank.v1
, but github.com/cosmos/cosmos-sdk/x/bank/v2
will be a separate go module with a separate module config type.
This practice will eventually allow us to use appconfig to load new versions of a module via a configuration change.
Effectively, there should be a 1:1 correspondence between a semantically versioned go module and a
versioned module config protobuf type, and major versioning bumps should occur whenever state machine breaking changes
are made to a module.
NOTE: SDK modules that are standalone go modules should not adopt semantic versioning until the concerns described in
ADR 054: Module Semantic Versioning are
addressed. The short-term solution for this issue was left somewhat unresolved. However, the easiest tactic is
likely to use a standalone API go module and follow the guidelines described in this comment: Link. For the time-being, it is recommended that
Cosmos SDK modules continue to follow tried and true 0-based versioning until an officially
recommended solution is provided. This section of the ADR will be updated when that happens and for now, this section
should be considered as a design recommendation for future adoption of semantic versioning.
Consequences
Backwards Compatibility
Modules which work with the new app wiring system do not need to drop their existingAppModule
and NewKeeper
registration paradigms. These two methods can live side-by-side for as long as is needed.
Positive
- wiring up new apps will be simpler, more succinct and less error-prone
- it will be easier to develop and test standalone SDK modules without needing to replicate all of simapp
- it may be possible to dynamically load modules and upgrade chains without needing to do a coordinated stop and binary upgrade using this mechanism
- easier plugin integration
- dependency injection framework provides more automated reasoning about dependencies in the project, with a graph visualization.
Negative
- it may be confusing when a dependency is missing although error messages, the GraphViz visualization, and global module registration may help with that
Neutral
- it will require work and education