Deep dive into DAPI and BFF architectural patterns
Noundry.ts supports two distinct API architecture patterns, each optimized for different use cases and project requirements.
Direct API
Direct backend that owns all business logic and data. Perfect for new applications where you control the entire stack.
Backend for Frontend
Intermediary API that orchestrates calls to another backend. Perfect for integrating with existing systems or when you need separation of concerns.
Own your entire stack with direct database access
In DAPI, routes directly handle all business logic, database access, and service integrations:
import { Hono } from 'hono';
import { db } from '../config/database';
import { users } from '../schema/schema';
import { cache } from '../config/cache';
import { emailQueue } from '../config/queue';
import bcrypt from 'bcryptjs';
const app = new Hono();
app.post('/register', async (c) => {
const { email, password, name } = await c.req.json();
// Direct database access via Drizzle ORM
const hashedPassword = await bcrypt.hash(password, 10);
const [user] = await db.insert(users).values({
email,
password: hashedPassword,
name
}).returning();
// Direct cache access
await cache.set(`user:${user.id}`, user, 3600);
// Direct queue access for background jobs
await emailQueue.add('send-welcome-email', {
userId: user.id,
email: user.email
});
return c.json({ user }, 201);
});
export default app;
Key Point: All business logic, data access, caching, and queue operations happen directly in the route handler. This is simple, fast, and perfect for applications where you control the entire stack.
packages/server/
├── src/
│ ├── config/
│ │ ├── database.ts # Direct Drizzle ORM connection
│ │ ├── cache.ts # Direct Redis client
│ │ ├── queue.ts # Direct BullMQ setup
│ │ └── email.ts # Direct email service
│ ├── middleware/
│ │ └── auth.ts # JWT generation & validation
│ ├── routes/
│ │ ├── users.ts # CRUD with direct DB access
│ │ ├── posts.ts # CRUD with direct DB access
│ │ └── billing.ts # CRUD with direct DB access
│ └── schema/
│ └── schema.ts # Drizzle ORM table schemas
Frontend-optimized API layer with separation of concerns
Services wrap HTTP calls to the real backend and transform data for frontend consumption:
export class UserService {
private backendUrl: string;
constructor(backendUrl: string) {
this.backendUrl = backendUrl;
}
async register(data: RegisterDto, bearerToken?: string) {
// Call real backend API with bearer token forwarding
const response = await fetch(`${this.backendUrl}/api/users/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(bearerToken && { 'Authorization': `Bearer ${bearerToken}` })
},
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error('Backend registration failed');
}
const user = await response.json();
// Transform backend data for frontend consumption
return {
id: user.id,
email: user.email,
displayName: user.firstName + ' ' + user.lastName, // Computed
initials: (user.firstName[0] + user.lastName[0]).toUpperCase(), // UI-specific
avatarColor: this.generateAvatarColor(user.id) // UI-specific
};
}
private generateAvatarColor(id: number): string {
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A'];
return colors[id % colors.length];
}
}
Key Point: The service layer handles HTTP communication with the backend, forwards bearer tokens for authentication, and transforms raw backend data into UI-friendly formats with computed fields.
Routes become thin - they just orchestrate service calls:
import { Hono } from 'hono';
import { UserService } from '../services/UserService';
import { env } from '../config/env';
const app = new Hono();
const userService = new UserService(env.BACKEND_API_URL);
app.post('/register', async (c) => {
// Extract data and bearer token from request
const data = await c.req.json();
const bearerToken = c.req.header('Authorization')?.replace('Bearer ', '');
// Just call the service - all logic is there
const user = await userService.register(data, bearerToken);
return c.json({ user }, 201);
});
export default app;
Simplicity: Routes are incredibly simple - extract request data, call the service, return response. All complexity is encapsulated in the service layer.
Combine multiple backend calls into a single frontend-optimized response:
async getUserDashboard(userId: number, token: string) {
// Fetch from multiple backend endpoints in parallel
const [user, orders, billing] = await Promise.all([
this.userService.getUser(userId, token),
this.orderService.getUserOrders(userId, token),
this.billingService.getBillingInfo(userId, token)
]);
// Return aggregated data optimized for frontend
return {
user,
recentOrders: orders.slice(0, 5),
totalSpent: orders.reduce((sum, o) => sum + o.amount, 0),
paymentMethod: billing.defaultCard
};
}
Transform verbose backend data into UI-friendly formats:
async getProducts(token: string) {
const backendProducts = await this.fetchFromBackend('/products', token);
// Transform to UI-friendly format with computed fields
return backendProducts.map(p => ({
id: p.productId,
name: p.productName,
price: `$${(p.priceInCents / 100).toFixed(2)}`,
inStock: p.inventoryCount > 0,
stockBadge: p.inventoryCount > 10 ? 'In Stock' :
p.inventoryCount > 0 ? 'Low Stock' : 'Out of Stock',
imageUrl: p.images[0]?.url || '/placeholder.png'
}));
}
Automatically forward authentication tokens to backend:
private async fetchFromBackend(endpoint: string, token: string) {
return fetch(`${this.backendUrl}${endpoint}`, {
headers: {
'Authorization': `Bearer ${token}`, // Token passed through
'Content-Type': 'application/json'
}
});
}
Cache expensive backend calls for improved performance:
async getProductCatalog(token: string) {
const cacheKey = 'product:catalog';
const cached = await getCache(cacheKey);
if (cached) return cached;
// Cache miss - fetch from backend
const products = await this.fetchFromBackend('/products', token);
await setCache(cacheKey, products, 300); // 5 min TTL
return products;
}
packages/server/
├── src/
│ ├── config/
│ │ ├── env.ts # BACKEND_API_URL configuration
│ │ └── cache.ts # Optional: cache backend responses
│ ├── middleware/
│ │ └── auth.ts # Pass-through or lightweight validation
│ ├── services/ # ⭐ Services wrap backend API calls
│ │ ├── BaseService.ts # HTTP client wrapper
│ │ ├── UserService.ts # User operations + transformations
│ │ ├── BillingService.ts # Billing operations + transformations
│ │ ├── ProductService.ts # Product operations + transformations
│ │ └── OrderService.ts # Order operations + transformations
│ ├── routes/
│ │ ├── users.ts # Thin - calls UserService
│ │ ├── billing.ts # Thin - calls BillingService
│ │ └── products.ts # Thin - calls ProductService
│ └── types/
│ └── backend-dtos.ts # DTOs for backend communication
Choose the right pattern for your project
| Feature | DAPI | BFF |
|---|---|---|
| Complexity | Low (single API) | Medium (API + Services) |
| Development Speed | Fast | Moderate |
| Best For | New apps, MVPs, small-medium | Existing backend, enterprise |
| Database Access | Direct via Drizzle ORM | Via backend API (HTTP) |
| Business Logic | In routes/controllers | In backend API |
| Data Transformation | Minimal | Extensive (UI-optimized) |
| Auth Pattern | JWT generation in API | Token pass-through to backend |
| Team Structure | Full-stack or single team | Split frontend/backend teams |
| Technology Lock-in | TypeScript/Bun/Node | Backend agnostic (any language) |
| Performance | Fast (direct DB) | Slower (extra network hop) |
| Scalability | Vertical | Horizontal (independent scaling) |
| Deployment | Monolithic or serverless | Separate frontend/backend |
Use this checklist to choose the right pattern
Examples: Todo apps, blogs, e-commerce sites, internal tools, CRM systems
Examples: Enterprise portals, multi-client systems, dashboards, mobile BFF, partner portals
Configure your environment based on the chosen pattern
# Database
DB_URL=postgresql://user:pass@host/db
# Auth
JWT_SECRET=your-secret-key
# Optional Services
REDIS_URL=redis://localhost:6379
SMTP_HOST=smtp.example.com
S3_BUCKET=my-uploads
# Backend API Configuration
BACKEND_API_URL=https://api.yourbackend.com
BACKEND_API_TIMEOUT=30000
# Optional: Auth validation
JWT_SECRET=your-secret-key
# Optional: Cache backend responses
REDIS_URL=redis://localhost:6379
CACHE_TTL=300
Run the CLI and select your preferred pattern during project generation
bunx @noundryfx/ndts-cli init