Providers
A cornerstone of the NextNonce backend architecture is the consistent and rigorous use of the Provider Pattern. This design principle is fundamental to creating a system that is modular, highly testable, and adaptable to future changes. By programming to interfaces rather than concrete implementations, the application avoids tight coupling between components and external services, ensuring long-term maintainability and flexibility.
The core idea is simple yet powerful: for any external-facing or complex logic (like authentication, data fetching, or caching), an abstraction layer (an interface) is defined. This interface dictates a contract — a set of methods that any concrete implementation must adhere to. The rest of the application interacts only with this interface, remaining completely unaware of the underlying details of the specific implementation being used.
This approach yields several critical advantages:
-
Interchangeability: If a decision is made to switch from one external data source to another (e.g., from Dune to a different analytics platform), only a new provider class needs to be created. The service consuming the data remains unchanged.
-
Testability: When writing unit tests for services, it's trivial to supply a mock implementation of a provider interface. This allows for testing the service's logic in complete isolation, without making actual network calls or database queries.
-
Separation of Concerns: Each provider is responsible for a single task. This keeps the core business logic in services clean and free from vendor-specific details.
A core architectural pattern is the use of provider interfaces. For instance:
-
AuthProvider
(/src/auth/interfaces/auth-provider.interface.ts
) abstracts the authentication logic, withSupabaseAuthProvider
being the concrete implementation. -
BalanceProvider
(/src/balance/interfaces/balance-provider.interface.ts
) defines the contract for fetching wallet balances, currently implemented byOkxDexBalanceProvider
. -
CacheProvider
(/src/cache/interfaces/cache-provider.interface.ts
) abstracts caching operations, withRedisCacheProvider
providing the implementation.
Deep Dive: The MetadataModule
Example
To understand how this pattern works in practice, let's examine the MetadataModule
. Its responsibility is to fetch metadata (like name, symbol, and decimals) for tokens a user holds.
The module follows a standard structure:
src/metadata
├── dto/ # Data Transfer Objects for this module
│ └── external-token-metadata.dto.ts
├── interfaces/ # The abstraction (the contract)
│ └── token-metadata-provider.interface.ts
├── providers/ # The concrete implementation
│ └── dune-token-metadata.provider.ts
├── metadata.module.ts # Wires everything together
└── metadata.service.ts # Consumes the provider and orchestrates logic
1. The Interface (token-metadata-provider.interface.ts
)
This file defines the contract. Any class that provides token metadata must adhere to this TokenMetadataProvider
interface.
// src/metadata/interfaces/token-metadata-provider.interface.ts
import { ExternalTokenMetadataDto } from '@/metadata/dto/external-token-metadata.dto';
// Use a Symbol as the injection token to avoid name collisions.
// This is a robust practice in NestJS for non-class providers.
export const TOKEN_METADATA_PROVIDER = Symbol('TOKEN_METADATA_PROVIDER');
export interface TokenMetadataProvider {
/**
* Given a wallet address and an array of chain names,
* return metadata for tokens that the wallet owns on those chains.
*/
getByWalletAddress(
walletAddress: string,
chainNames: string[],
): Promise<ExternalTokenMetadataDto[] | undefined>;
}
The key element here is TOKEN_METADATA_PROVIDER
. Since an interface doesn't exist at runtime in JavaScript, we use a Symbol
as a unique, collision-proof token for NestJS's dependency injection system.
2. The Concrete Implementation (dune-token-metadata.provider.ts
)
This class is the concrete implementation of the TokenMetadataProvider
interface. It knows how to communicate specifically with the Dune Sim API.
Its responsibilities include:
-
Implementing the
getByWalletAddress
method. -
Handling Dune-specific API details, like the base URL, authentication headers (
X-Sim-Api-Key
), and rate limiting. -
Mapping the response from the Dune API into the application's standardized
ExternalTokenMetadataDto
.
This class contains all the messy, vendor-specific logic, keeping it isolated from the rest of the application.
3. Wiring It All Together (metadata.module.ts
)
The MetadataModule
is where the magic happens. It tells the NestJS dependency injection system how to resolve the TokenMetadataProvider
interface.
// src/metadata/metadata.module.ts
@Module({
imports: [ChainModule],
providers: [
// This is the crucial part
{
provide: TOKEN_METADATA_PROVIDER, // When this token is requested...
useClass: DuneTokenMetadataProvider, // ...provide this concrete class.
},
MetadataService,
],
exports: [MetadataService],
})
export class MetadataModule {}
This configuration explicitly states: "When any part of the application asks for the dependency identified by the TOKEN_METADATA_PROVIDER
token, instantiate and provide the DuneTokenMetadataProvider
class."
4. Consuming the Provider (metadata.service.ts
)
The MetadataService
is the consumer of the provider. It orchestrates the business logic, such as fetching data and caching it. Notice its constructor:
// src/metadata/metadata.service.ts
@Injectable()
export class MetadataService {
constructor(
private readonly databaseService: DatabaseService,
private readonly cacheService: CacheService,
private readonly chainService: ChainService,
@Inject(TOKEN_METADATA_PROVIDER)
private readonly tokenMetadataProvider: TokenMetadataProvider,
) {
// ...
}
async getTokensWithMetadataByWalletAddress(
walletAddress: string,
chainNames: string[],
): Promise<TokenWithMetadata[]> {
// ...
const providedTokensMetadata =
await this.tokenMetadataProvider.getByWalletAddress(
walletAddress,
chainNames,
);
// ...
}
}
The most important takeaway is that MetadataService
has no idea that DuneTokenMetadataProvider
exists. It only depends on the TokenMetadataProvider
interface. It knows it can call getByWalletAddress
, but it doesn't know or care how that method is implemented.
This makes the MetadataService
incredibly robust and flexible. If we decide to switch from Dune to another provider, we would simply:
-
Create a new provider class, e.g.,
AlchemyTokenMetadataProvider
. -
Update a single line in
metadata.module.ts
touseClass: AlchemyTokenMetadataProvider
.
No code would need to be changed in MetadataService
itself. This pattern is the foundation of the backend's clean, decoupled, and scalable architecture.