Dependency Injection Pattern
How and why the payment package uses dependency injection to remain framework-agnostic.
Dependency Injection Pattern
The BISO Sites payment package demonstrates clean architecture principles through dependency injection (DI). This document explains what DI is, why we use it, and how to apply this pattern in your own code.
What is Dependency Injection?
Dependency Injection is a design pattern where dependencies are "injected" (passed in) to a function or class, rather than being created or imported directly inside it.
Without DI (❌ Tight Coupling)
// packages/payment/vipps.ts
import { createSessionClient } from '@repo/api/server'; // Next.js dependency!
export async function createCheckout(params) {
const { db } = await createSessionClient(); // Importing Next.js runtime
const order = await db.createDocument(...);
return order;
}Problems:
- ❌ Package directly imports Next.js runtime (
cookies()) - ❌ Cannot be used outside Next.js
- ❌ Tight coupling between package and framework
- ❌ Harder to test (need to mock Next.js environment)
- ❌ Hidden dependencies
With DI (✅ Loose Coupling)
// packages/payment/vipps.ts
// No Next.js imports! Framework-agnostic
export async function createCheckout(params, db) {
// db is passed as parameter - dependency injection!
const order = await db.createDocument(...);
return order;
}Benefits:
- ✅ Package is framework-agnostic
- ✅ Clear separation of concerns
- ✅ Easy to test (mock db)
- ✅ No hidden dependencies
- ✅ Can be reused in other apps
- ✅ Follows SOLID principles
The Problem: Framework Lock-in
Original Implementation
The payment package originally looked like this:
// packages/payment/vipps.ts
import { createSessionClient } from '@repo/api/server';
import "server-only"; // Next.js specific!
export async function createCheckoutSession(params: CheckoutParams) {
// Directly imports and calls Next.js function
const { db } = await createSessionClient();
// Business logic
const order = await db.createDocument(
'database_id',
'orders',
'unique()',
{
userId: params.userId,
total: params.total,
status: 'pending',
// ...
}
);
// Call Vipps API
const vippsSession = await fetch(VIPPS_API, {
method: 'POST',
body: JSON.stringify({
orderId: order.$id,
amount: params.total,
// ...
}),
});
return vippsSession.url;
}Why This is Problematic
- Framework Dependency: Package depends on Next.js runtime
- Testing Difficulty: Must mock entire Next.js environment
- Limited Reusability: Can't use in Express, Fastify, CLI tools, etc.
- Hidden Dependencies: Not obvious what the function needs
- Violation of SRP: Function handles both data access AND business logic
The Solution: Dependency Injection
Refactored Implementation
// packages/payment/vipps.ts
// No framework imports!
import { ID } from 'node-appwrite';
import type { Databases } from 'node-appwrite';
export async function createCheckoutSession(
params: CheckoutParams,
db: Databases // ← Dependency injected!
) {
// Business logic (unchanged)
const order = await db.createDocument(
'database_id',
'orders',
ID.unique(),
{
userId: params.userId,
total: params.total,
status: 'pending',
// ...
}
);
// Call Vipps API (unchanged)
const vippsSession = await fetch(VIPPS_API, {
method: 'POST',
body: JSON.stringify({
orderId: order.$id,
amount: params.total,
// ...
}),
});
return vippsSession.url;
}Wrapper for Next.js (Convenience)
// packages/payment/actions.ts
'use server';
import { createSessionClient } from '@repo/api/server';
import { createCheckoutSession } from './vipps';
export async function initiateVippsCheckout(params: CheckoutParams) {
// Next.js-specific code stays here
const { db } = await createSessionClient();
// Pass db to core function
const checkoutUrl = await createCheckoutSession(params, db);
// Next.js-specific redirect
redirect(checkoutUrl);
}Now:
- ✅ Core logic (
vipps.ts) is framework-agnostic - ✅ Next.js code is isolated in
actions.ts - ✅ Clear separation of concerns
Architecture Diagram
Key Points:
- Green (Business Logic): Framework-agnostic, testable
- Blue (Next.js Layer): Framework-specific, thin wrapper
- Dependency Flow: One-way, clear boundaries
Benefits in Practice
Easy Testing
// test/vipps.test.ts
import { createCheckoutSession } from '@repo/payment/vipps';
describe('createCheckoutSession', () => {
it('creates order and returns checkout URL', async () => {
// Mock database - no Next.js needed!
const mockDb = {
createDocument: jest.fn().mockResolvedValue({
$id: 'order_123',
total: 1000,
}),
};
const result = await createCheckoutSession(
{ userId: 'user_1', total: 1000 },
mockDb as any
);
expect(mockDb.createDocument).toHaveBeenCalled();
expect(result).toContain('vipps.no');
});
});No Next.js mocking required!
Reusability
Can now use in different contexts:
// In Next.js App (web/admin)
import { createSessionClient } from '@repo/api/server';
import { createCheckoutSession } from '@repo/payment/vipps';
const { db } = await createSessionClient();
await createCheckoutSession(params, db);// In Express API
import { Databases } from 'node-appwrite';
import { createCheckoutSession } from '@repo/payment/vipps';
const db = new Databases(client);
await createCheckoutSession(params, db);// In CLI Tool
import { Databases } from 'node-appwrite';
import { createCheckoutSession } from '@repo/payment/vipps';
const db = new Databases(adminClient);
await createCheckoutSession(params, db);Clear Dependencies
Function signature explicitly shows what it needs:
// ✅ Clear: I need params and a database
function createCheckoutSession(params: CheckoutParams, db: Databases)
// ❌ Hidden: What does this need? Have to read the code
function createCheckoutSession(params: CheckoutParams)SOLID Principles
This pattern follows SOLID principles:
Single Responsibility Principle (SRP)
// ✅ Good: Each function has one responsibility
// vipps.ts - Only business logic
export async function createCheckoutSession(params, db) {
// Pure business logic
}
// actions.ts - Only Next.js integration
export async function initiateVippsCheckout(params) {
const { db } = await createSessionClient(); // Framework
return createCheckoutSession(params, db); // Business logic
}Dependency Inversion Principle (DIP)
// High-level module (vipps.ts) depends on abstraction (Databases interface)
import type { Databases } from 'node-appwrite';
export async function createCheckoutSession(
params: CheckoutParams,
db: Databases // ← Interface, not concrete implementation
) {
// ...
}
// Low-level modules (Next.js, Express) provide the implementation
const { db } = await createSessionClient();
await createCheckoutSession(params, db);When to Use Dependency Injection
✅ Use DI When:
- Creating shared packages that should work in multiple environments
- Testing is important and you want to mock dependencies easily
- Framework-agnostic code is desired
- Dependencies are complex and need to be swapped
- Following clean architecture principles
❌ Don't Overcomplicate:
- Simple utilities that don't need testing
- Framework-specific components (React components, Next.js layouts)
- When it adds no value (don't DI everything just because)
Patterns in BISO SItes
Pattern 1: Package Core + Action Wrapper
// Package core (framework-agnostic)
// packages/payment/vipps.ts
export async function coreFunction(params, dependencies) {
// Business logic
}
// Next.js wrapper
// packages/payment/actions.ts
'use server';
export async function actionWrapper(params) {
const dependencies = await getDependencies();
return coreFunction(params, dependencies);
}Pattern 2: Server Component Direct Usage
// Server Component
import { createSessionClient } from '@repo/api/server';
import { coreFunction } from '@repo/payment/vipps';
export default async function Page() {
const { db } = await createSessionClient();
const result = await coreFunction(params, db);
// ...
}Pattern 3: API Route Usage
// API Route Handler
import { createAdminClient } from '@repo/api/server';
import { handleWebhook } from '@repo/payment/vipps';
export async function POST(request: NextRequest) {
const { db } = await createAdminClient();
await handleWebhook(data, db);
return NextResponse.json({ success: true });
}Migration Example
If you have tightly coupled code, here's how to refactor:
Before
// packages/email/sender.ts
import { createSessionClient } from '@repo/api/server';
export async function sendEmail(to: string, subject: string, body: string) {
const { db } = await createSessionClient();
// Log to database
await db.createDocument('emails', ID.unique(), {
to,
subject,
sentAt: new Date().toISOString(),
});
// Send email
await externalEmailService.send({ to, subject, body });
}After (Refactored with DI)
// packages/email/sender.ts
import type { Databases } from 'node-appwrite';
export async function sendEmail(
to: string,
subject: string,
body: string,
db: Databases // ← Injected dependency
) {
// Log to database
await db.createDocument('emails', ID.unique(), {
to,
subject,
sentAt: new Date().toISOString(),
});
// Send email
await externalEmailService.send({ to, subject, body });
}// packages/email/actions.ts
'use server';
import { createSessionClient } from '@repo/api/server';
import { sendEmail as sendEmailCore } from './sender';
export async function sendEmail(to: string, subject: string, body: string) {
const { db } = await createSessionClient();
return sendEmailCore(to, subject, body, db);
}Best Practices
Inject at Function Level
// ✅ Good: Function parameter
export async function doSomething(params, db: Databases) {
// ...
}
// ❌ Avoid: Module-level import
import { db } from './db';
export async function doSomething(params) {
// Tightly coupled to module
}Use TypeScript Interfaces
// ✅ Good: Depend on interface
import type { Databases } from 'node-appwrite';
export async function doSomething(db: Databases) {
// Can accept any implementation of Databases
}Keep Core Logic Pure
// ✅ Good: Pure business logic
export async function calculateTotal(items: Item[], db: Databases) {
const taxes = await db.getDocument('config', 'taxes');
return items.reduce((sum, item) => sum + item.price, 0) * (1 + taxes.rate);
}
// ❌ Avoid: Mixed concerns
export async function calculateTotal(items: Item[]) {
const { db } = await createSessionClient(); // Framework coupling
const taxes = await db.getDocument('config', 'taxes');
return items.reduce((sum, item) => sum + item.price, 0) * (1 + taxes.rate);
}Create Convenience Wrappers
// Core function with DI
export async function coreFunction(params, db) { /* ... */ }
// Convenient wrapper for Next.js
export async function nextjsFunction(params) {
const { db } = await createSessionClient();
return coreFunction(params, db);
}Common Questions
Q: Doesn't this add boilerplate?
A: Slightly, but the benefits outweigh the cost. The extra parameter is minimal compared to the gains in testability and reusability.
Q: When should I NOT use DI?
A: For framework-specific code like React components, Next.js layouts, or simple utilities that don't need testing.
Q: Can I inject multiple dependencies?
A: Yes! Pass an object:
export async function doSomething(
params,
deps: { db: Databases; storage: Storage; cache: Cache }
) {
// Use deps.db, deps.storage, deps.cache
}Q: What about constructor injection (classes)?
A: Function injection is simpler for this codebase, but class-based DI is also valid:
class VippsService {
constructor(private db: Databases) {}
async createCheckout(params) {
// Use this.db
}
}Summary
Dependency Injection in the BISO Sites payment package:
- ✅ Keeps packages framework-agnostic
- ✅ Makes testing easy (mock the dependencies)
- ✅ Follows SOLID principles
- ✅ Enables reusability across different contexts
- ✅ Makes dependencies explicit and clear
This pattern can be applied to any package where you want to maintain framework independence and improve testability.
By using dependency injection, BISO Sites packages follow clean architecture principles, making them maintainable, testable, and reusable.
Next Steps
- Payment Package - See DI in action
- Development Guides - Apply when building features
- Testing Guide - Learn to test with DI
