Skip to content

Extending NestRS

How to plug your own implementation into a swappable concern — the extension contract, the three selection modes, and where your configuration lives.

Some parts of NestRS are meant to be replaced: the ORM, the queue backend, the rate-limit store, a social provider. This page is the contract you implement to do that, and — just as important — how the framework decides what is swappable in the first place.

A concern you can plug into ships an extension contract: the trait you implement, the seam that makes your implementation reachable from the container, and the sentence describing what happens when there is more than one.

If that contract cannot be written, the concern is not swappable, and the crate says so in its module docs. A trait with no contract beside it would be an extension you have to reverse-engineer; a client with no trait at all is a closed door that looks open. Neither is left to inference.

This is the question most likely to surprise you, because it explains why some ports look thin.

Delegated. A third-party library is already the multi-driver abstraction, and the NestRS crate is a thin adapter over it that does not let the vendor’s types leak.

the crate that binds the portdelegates towhich already drives
nest-rs-seaormsea-ormPostgreSQL, MySQL, SQLite
nest-rs-redisapalisRedis, SQL backends
nest-rs-storageobject_storeS3, GCS, Azure, local filesystem, in-memory

Note which column the crate names sit in. The question is answered by whoever binds the port, never by the crate that declares it — nest-rs-database’s entire manifest is one dependency on tokio, and nest-rs-queue names no apalis. A port crate depending on nothing is the normal case.

When a concern is delegated, the port exists for exactly one move — swapping the vendor — so its contract is thin and it declares no configuration of its own. That thinness is the correct outcome, not an unfinished one. If you want a different object store, you do not write a NestRS driver: you point object_store at it.

Owned. Nothing abstracts the concern, so NestRS defines the trait, the registration seam and the arbitration sentence itself — ThrottlerStore, SocialProvider, Strategy.

Three modes. They are not interchangeable, and which one a concern uses tells you where your configuration goes.

By import. Exactly one implementation, chosen by which binding your app imports beside the port’s module. Consumers inject dyn Port and never name the backend, so swapping it is a composition-root edit rather than a change to your handlers. The port’s own default is an ordinary factory, so a vendor binding supersedes it wherever it sits in imports; two vendor bindings contest, and the boot fails naming both — where the binding declares itself; see the caveat below.

apps/api/src/module.rs
ThrottlerModule::for_root(None), // the port: policy, guard, counters in this process
RedisThrottlerModule, // the binding: counters shared across instances

By type parameter. You name the implementation in an alias, and the generic host is instantiated with it. No arbitration exists and none is owed: two instantiations are two distinct types, so nothing can be contested.

crates/features/src/authn/strategy.rs
pub type AuthnStrategy = JwtStrategy<Claims>;
pub type AuthnGuard = nest_rs::authn::AuthnGuard<AuthnStrategy>;

The same shape carries AbilityGuard<F: AbilityFactory> and the GraphQL and MCP bridges. This is the mode NestRS uses most.

By configuration. The import only opens the gate; configuration decides which members are active, from zero to all of them. Registering a provider is not activating it — that distinction is the whole mechanism.

NESTRS_SOCIAL__<KEY>__*outcome
completethe provider is active
absent entirelyinert, with one boot warn — its routes 404
partial, or invalidthe boot fails, naming the provider

It follows from the contract; nothing is arbitrated.

A port owns a config namespace if and only if its contract requires you to honour one. The throttler’s does, so NESTRS_THROTTLER__LIMIT and NESTRS_THROTTLER__WINDOW_SECS survive a backend swap unchanged — they are the policy every store honours, and your store reads them through the shared seam.

A delegated port’s contract requires nothing of you, so it owns no namespace. Your own settings take their namespace from where they are declared — read off the path, exactly as a type name is: your crate’s word, then the binding folder’s when the config sits in one, joined by __. The resource your crate opens is its own subject, so its config sits at the crate root and wears your crate’s word: nest-rs-redis/src/config.rsRedisConfigNESTRS_REDIS__*, and nest-rs-seaorm/src/config.rsSeaOrmConfigNESTRS_SEAORM__*. A setting one binding owns sits in that binding’s folder and wears both words — nest-rs-redis/src/worker/config.rsRedisWorkerConfigNESTRS_REDIS__WORKER__*. From the variable an operator can name your crate and your type; from your module a developer can name the variable.

Selection by configuration is the case where the member is a folder of your crate, hence two segments — NESTRS_SOCIAL__GITHUB__* beside NESTRS_SOCIAL__GOOGLE__*. Selection by type parameter needs no namespace at all. Nothing else needs either.

nest-rs-database ships only the seam: the Executor trait, the ExecutorScope tag, and the task-local plumbing that carries “the current handle on a unit of work” across the framework. Its own module docs carry the contract:

  1. Implement Executor on the type representing your handle — a pool, a transaction, or an enum forwarding to either.
  2. Ship a module that wraps each HTTP request in with_request_executor with your Arc<dyn Executor>. Do the same with with_job_executor for worker transports.
  3. Provide your own query API that calls current_executor and downcasts to your concrete type.

Step 3 is deliberate. Repo, CrudService and Bind couple tightly to SeaORM’s EntityTrait and Model — that coupling is where their leverage comes from, and a generic abstraction over them would lose most of it. Your integration ships its own row-level-filter equivalent rather than inheriting one.

nest-rs-social ships an open provider contract. You publish an independent crate that depends on it, implements SocialProvider and SocialProviderConfig, and submits one SocialProviderEntry — the same public seam the first-party GitHub and Google providers use, with no crate-private shortcut.

A social provider is not a DI provider: it is never injected by type, only reached through SocialRegistry as Arc<dyn SocialProvider>. So discovery is gated by SocialModule — an app that never imports it sees no entry at all — and within that gate your provider’s own #[config] decides its fate, per the table above. SocialModule itself takes no configuration, because it never learns which providers exist.

Every port above carries its contract on the page that owns the concern, not in the crate’s module docs — so start from the page rather than from docs.rs:

PortContract
JobProducerqueue / writing a driver
Repo / the database bindingdatabase / writing a driver
ThrottlerStorerate limiting, including the arbitration sentence BACKEND_REMEDY
Strategysecurity / authentication
SocialProvidersecurity / social login

nest-rs-storage is a different case, and the honest statement is that it is not extensible today. It delegates to object_store, which drives S3, GCS, Azure, the local filesystem and memory — but this crate pins the S3 driver concretely, and every StorageConfig field is S3’s own (endpoint, region, access_key, bucket, …). Reaching another backend is a change inside the crate, not something you can do from your app. Three shipped surfaces still say otherwise — the crate docs, the Storage type’s own documentation, and the crates.io description — and this page is the one that is right. If you need another backend, say so on the issue tracker: that is the signal that decides whether the driver surface gets opened.

  • Modules — how an import registers what you ship.
  • Providers — injection, and why a discovered provider is not one.
  • Configuration — the .env cascade, namespaces, and the dual path between a pinned struct and an environment variable.
  • Social login — the open provider contract, end to end.