v 0.1.97

Routing Patterns

Choose the right architectural pattern for your explicit routes. Use these patterns to organize handlers and business logic effectively as your application grows.

Pattern Overview

PatternBest ForComplexityTestability
Inline HandlersSmall projects, prototypesLowBasic
Grouped HandlersMedium apps, clear separationMediumGood
Factory FunctionsTesting, dependency injectionMediumExcellent
MVC ClassesOOP preference, large teamsHighGood
Service LayerComplex domains, reusabilityHighExcellent

Prerequisites

The examples below assume the following shared setup. You can copy this into your project to run the examples:

// eco.config.ts
import { ConfigBuilder } from '@ecopages/core';
 
export default await new ConfigBuilder()
  .setBaseUrl('http://localhost:3000')
  .build();
 
// src/db.ts
// Mock database client for demonstration
export const db = {
  posts: {
    findMany: async (opts?: any) => [],
    findUnique: async (opts?: any) => null,
    create: async (opts?: any) => ({ ...opts.data }),
    update: async (opts?: any) => ({ ...opts.data }),
    delete: async (opts?: any) => true,
  }
};
 
// src/views/post-list.tsx
import { eco } from '@ecopages/core';
 
const PostListView = eco.page({
  render: ({ posts }: { posts: any[] }) => (
    `<ul>${posts.map(p => `<li>${p.title}</li>`).join('')}</ul>`
  )
});
 
export default PostListView;
 
// src/views/post.tsx
import { eco } from '@ecopages/core';
 
const PostView = eco.page({
  render: ({ title, content }: { title: string; content: string }) => (
    `<article><h1>${title}</h1><p>${content}</p></article>`
  )
});
 
export default PostView;

Pattern A: Inline Handlers

For small projects or rapid prototyping, define everything inline in app.ts.

When to Use

Example

import { EcopagesApp } from '@ecopages/core/adapters/bun/create-app';
import { HttpError } from '@ecopages/core/errors';
import { db } from './src/db';
import appConfig from './eco.config';
 
const app = new EcopagesApp({ appConfig });
 
app.get('/posts', async (ctx) => {
	const { default: PostListView } = await import('./src/views/post-list');
	const posts = await db.posts.findMany({
		where: { status: 'published' },
		orderBy: { createdAt: 'desc' }
	});
	return ctx.render(PostListView, { posts });
});
 
app.get('/posts/:slug', async (ctx) => {
	const { default: PostView } = await import('./src/views/post');
	const post = await db.posts.findUnique({
		where: { slug: ctx.request.params.slug }
	});
	if (!post) throw HttpError.NotFound('Post not found');
	return ctx.render(PostView, post);
});
 
app.post('/api/posts', async (ctx) => {
	const { title, content } = ctx.body;
	const post = await db.posts.create({
		data: { title, content, status: 'draft' }
	});
	return ctx.json(post, { status: 201 });
});
 
await app.start();

Pros & Cons

Pros:

Cons:

Pattern B: Grouped Handlers

Extract handlers to separate files, organized by resource or feature.

When to Use

File Structure

src/
├── handlers/
│   ├── posts.ts
│   ├── users.ts
│   └── comments.ts
├── views/
└── db.ts
app.ts

Example

// src/handlers/posts.ts
import type { ApiHandlerContext } from '@ecopages/core';
import { HttpError } from '@ecopages/core/errors';
import { db } from '../db';
 
export async function index(ctx: ApiHandlerContext) {
	const { default: PostListView } = await import('../views/post-list');
	const posts = await db.posts.findMany({
		where: { status: 'published' },
		orderBy: { createdAt: 'desc' }
	});
	return ctx.render(PostListView, { posts });
}
 
export async function show(ctx: ApiHandlerContext) {
	const { default: PostView } = await import('../views/post');
	const post = await db.posts.findUnique({
		where: { slug: ctx.request.params.slug },
		include: { author: true }
	});
	if (!post) throw HttpError.NotFound('Post not found');
	return ctx.render(PostView, post);
}
 
export async function create(ctx: ApiHandlerContext) {
	const { title, content } = ctx.body;
	const post = await db.posts.create({
		data: { title, content, status: 'draft' }
	});
	return ctx.json(post, { status: 201 });
}
 
export async function update(ctx: ApiHandlerContext) {
	const { slug } = ctx.request.params;
	const post = await db.posts.update({
		where: { slug },
		data: ctx.body
	});
	return ctx.json(post);
}
 
export async function destroy(ctx: ApiHandlerContext) {
	const { slug } = ctx.request.params;
	await db.posts.delete({ where: { slug } });
	return ctx.json({ deleted: true });
}
// app.ts
import { EcopagesApp } from '@ecopages/core/adapters/bun/create-app';
import * as posts from './src/handlers/posts';
import * as users from './src/handlers/users';
import appConfig from './eco.config';
 
const app = new EcopagesApp({ appConfig });
 
// Posts routes
app.get('/posts', posts.index);
app.get('/posts/:slug', posts.show);
app.post('/api/posts', posts.create);
app.patch('/api/posts/:slug', posts.update);
app.delete('/api/posts/:slug', posts.destroy);
 
// Users routes
app.get('/users/:username', users.show);
app.post('/api/users', users.create);
 
await app.start();

Pros & Cons

Pros:

Cons:

Pattern C: Factory Functions

Use factory functions to inject dependencies, making handlers fully testable without mocks.

When to Use

File Structure

src/
├── handlers/
│   └── posts.ts
├── services/
│   └── db.ts
├── views/
└── types.ts
app.ts

Example

// src/handlers/posts.ts
import type { ApiHandlerContext } from '@ecopages/core';
import type { Database } from '../types';
import { HttpError } from '@ecopages/core/errors';
 
export function createPostsHandlers(db: Database) {
	return {
		index: async (ctx: ApiHandlerContext) => {
			const { default: PostListView } = await import('../views/post-list');
			const posts = await db.posts.findMany({
				where: { status: 'published' },
				orderBy: { createdAt: 'desc' }
			});
			return ctx.render(PostListView, { posts });
		},
 
		show: async (ctx: ApiHandlerContext) => {
			const { default: PostView } = await import('../views/post');
			const post = await db.posts.findUnique({
				where: { slug: ctx.request.params.slug },
				include: { author: true }
			});
			if (!post) throw HttpError.NotFound('Post not found');
			return ctx.render(PostView, post);
		},
 
		create: async (ctx: ApiHandlerContext) => {
			const { title, content } = ctx.body;
			const post = await db.posts.create({
				data: { title, content, status: 'draft' }
			});
			return ctx.json(post, { status: 201 });
		}
	};
}
// app.ts
import { EcopagesApp } from '@ecopages/core/adapters/bun/create-app';
import { createPostsHandlers } from './src/handlers/posts';
import { db } from './src/db';
import appConfig from './eco.config';
 
const app = new EcopagesApp({ appConfig });
const posts = createPostsHandlers(db);
 
app.get('/posts', posts.index);
app.get('/posts/:slug', posts.show);
app.post('/api/posts', posts.create);
 
await app.start();

Testing

// tests/handlers/posts.test.ts
import { describe, test, expect } from 'bun:test';
import { createPostsHandlers } from '../../src/handlers/posts';
import type { Database } from '../../src/types';
 
const mockDb: Database = {
	posts: {
		findMany: async () => [
			{ id: '1', title: 'Test Post', slug: 'test-post', content: 'Content' }
		],
		findUnique: async ({ where }) => 
			where.slug === 'test-post' 
				? { id: '1', title: 'Test Post', slug: 'test-post', content: 'Content' }
				: null,
		create: async ({ data }) => ({ id: '2', ...data })
	}
};
 
describe('Posts Handlers', () => {
	const posts = createPostsHandlers(mockDb);
 
	test('index returns all posts', async () => {
		const ctx = {
			render: (view: any, props: any) => props
		};
		const result = await posts.index(ctx as any);
		expect(result.posts).toHaveLength(1);
	});
 
	test('show returns single post', async () => {
		const ctx = {
			request: { params: { slug: 'test-post' } },
			render: (view: any, props: any) => props
		};
		const result = await posts.show(ctx as any);
		expect(result.title).toBe('Test Post');
	});
});

Pros & Cons

Pros:

Cons:

Pattern D: MVC Classes

Use classes to organize handlers with methods, familiar to developers from traditional MVC frameworks.

When to Use

File Structure

src/
├── controllers/
│   └── posts.controller.ts
├── services/
│   └── posts.service.ts
├── views/
└── types.ts
app.ts

Example

// src/services/posts.service.ts
import type { Database } from '../types';
 
export class PostsService {
	constructor(private db: Database) {}
 
	async findAll() {
		return this.db.posts.findMany({
			where: { status: 'published' },
			orderBy: { createdAt: 'desc' }
		});
	}
 
	async findBySlug(slug: string) {
		return this.db.posts.findUnique({
			where: { slug },
			include: { author: true }
		});
	}
 
	async create(data: { title: string; content: string }) {
		return this.db.posts.create({
			data: { ...data, status: 'draft' }
		});
	}
 
	async update(slug: string, data: Partial<{ title: string; content: string }>) {
		return this.db.posts.update({ where: { slug }, data });
	}
 
	async delete(slug: string) {
		return this.db.posts.delete({ where: { slug } });
	}
}
// src/controllers/posts.controller.ts
import type { ApiHandlerContext } from '@ecopages/core';
import { HttpError } from '@ecopages/core/errors';
import { PostsService } from '../services/posts.service';
 
export class PostsController {
	constructor(private postsService: PostsService) {}
 
	async index(ctx: ApiHandlerContext) {
		const { default: PostListView } = await import('../views/post-list');
		const posts = await this.postsService.findAll();
		return ctx.render(PostListView, { posts });
	}
 
	async show(ctx: ApiHandlerContext) {
		const { default: PostView } = await import('../views/post');
		const post = await this.postsService.findBySlug(ctx.request.params.slug);
		if (!post) throw HttpError.NotFound('Post not found');
		return ctx.render(PostView, post);
	}
 
	async create(ctx: ApiHandlerContext) {
		const { title, content } = ctx.body;
		const post = await this.postsService.create({ title, content });
		return ctx.json(post, { status: 201 });
	}
 
	async update(ctx: ApiHandlerContext) {
		const { slug } = ctx.request.params;
		const post = await this.postsService.update(slug, ctx.body);
		return ctx.json(post);
	}
 
	async destroy(ctx: ApiHandlerContext) {
		const { slug } = ctx.request.params;
		await this.postsService.delete(slug);
		return ctx.json({ deleted: true });
	}
}
// app.ts
import { EcopagesApp } from '@ecopages/core/adapters/bun/create-app';
import { PostsController } from './src/controllers/posts.controller';
import { PostsService } from './src/services/posts.service';
import { db } from './src/db';
import appConfig from './eco.config';
 
const app = new EcopagesApp({ appConfig });
 
const postsService = new PostsService(db);
const postsController = new PostsController(postsService);
 
app.get('/posts', (ctx) => postsController.index(ctx));
app.get('/posts/:slug', (ctx) => postsController.show(ctx));
app.post('/api/posts', (ctx) => postsController.create(ctx));
app.patch('/api/posts/:slug', (ctx) => postsController.update(ctx));
app.delete('/api/posts/:slug', (ctx) => postsController.destroy(ctx));
 
await app.start();

Pros & Cons

Pros:

Cons:

Pattern E: Service Layer

Separate business logic (service) from data transformation (adapter) and HTTP handling (handler).

When to Use

File Structure

src/
├── handlers/
│   └── posts.ts
├── services/
│   └── blog.service.ts
├── adapters/
│   └── post.adapter.ts
├── views/
└── types.ts
app.ts

Example

// src/services/blog.service.ts
import type { Database } from '../types';
 
export function createBlogService(db: Database) {
	return {
		async getAllPublishedPosts() {
			return db.posts.findMany({
				where: { status: 'published' },
				orderBy: { createdAt: 'desc' },
				include: { author: true, tags: true }
			});
		},
 
		async getPostBySlug(slug: string) {
			return db.posts.findUnique({
				where: { slug },
				include: { author: true, tags: true, comments: true }
			});
		},
 
		async createDraftPost(data: { title: string; content: string; authorId: string }) {
			return db.posts.create({
				data: {
					...data,
					status: 'draft',
					slug: this.generateSlug(data.title)
				}
			});
		},
 
		async publishPost(slug: string) {
			const post = await db.posts.findUnique({ where: { slug } });
			if (!post) return null;
			
			return db.posts.update({
				where: { slug },
				data: { status: 'published', publishedAt: new Date() }
			});
		},
 
		generateSlug(title: string) {
			return title
				.toLowerCase()
				.replace(/[^a-z0-9]+/g, '-')
				.replace(/^-|-$/g, '');
		}
	};
}
// src/adapters/post.adapter.ts
import type { Post, Author, Tag } from '../types';
 
export const postAdapter = {
	toListView(posts: (Post & { author: Author; tags: Tag[] })[]) {
		return posts.map((post) => ({
			slug: post.slug,
			title: post.title,
			excerpt: this.createExcerpt(post.content),
			authorName: post.author.name,
			authorAvatar: post.author.avatarUrl,
			tags: post.tags.map((tag) => tag.name),
			publishedDate: this.formatDate(post.publishedAt)
		}));
	},
 
	toDetailView(post: Post & { author: Author; tags: Tag[]; comments: Comment[] }) {
		return {
			slug: post.slug,
			title: post.title,
			content: post.content,
			authorName: post.author.name,
			authorBio: post.author.bio,
			authorAvatar: post.author.avatarUrl,
			tags: post.tags.map((tag) => ({ name: tag.name, slug: tag.slug })),
			publishedDate: this.formatDate(post.publishedAt),
			readingTime: this.calculateReadingTime(post.content),
			commentCount: post.comments.length
		};
	},
 
	createExcerpt(content: string, maxLength = 150) {
		return content.length > maxLength
			? `${content.slice(0, maxLength)}...`
			: content;
	},
 
	formatDate(date: Date | null) {
		if (!date) return null;
		return new Intl.DateTimeFormat('en-US', {
			year: 'numeric',
			month: 'long',
			day: 'numeric'
		}).format(date);
	},
 
	calculateReadingTime(content: string) {
		const wordsPerMinute = 200;
		const words = content.trim().split(/\s+/).length;
		const minutes = Math.ceil(words / wordsPerMinute);
		return `${minutes} min read`;
	}
};
// src/handlers/posts.ts
import type { ApiHandlerContext } from '@ecopages/core';
import type { BlogService, PostAdapter } from '../types';
import { HttpError } from '@ecopages/core/errors';
 
export function createPostsHandlers(
	service: BlogService,
	adapter: PostAdapter
) {
	return {
		async index(ctx: ApiHandlerContext) {
			const { default: PostListView } = await import('../views/post-list');
			const posts = await service.getAllPublishedPosts();
			const viewData = adapter.toListView(posts);
			return ctx.render(PostListView, { posts: viewData });
		},
 
		async show(ctx: ApiHandlerContext) {
			const { default: PostView } = await import('../views/post');
			const post = await service.getPostBySlug(ctx.request.params.slug);
			if (!post) throw HttpError.NotFound('Post not found');
			const viewData = adapter.toDetailView(post);
			return ctx.render(PostView, viewData);
		},
 
		async createDraft(ctx: ApiHandlerContext) {
			const { title, content, authorId } = ctx.body;
			const post = await service.createDraftPost({ title, content, authorId });
			return ctx.json(post, { status: 201 });
		},
 
		async publish(ctx: ApiHandlerContext) {
			const { slug } = ctx.request.params;
			const post = await service.publishPost(slug);
			if (!post) throw HttpError.NotFound('Post not found');
			return ctx.json(post);
		}
	};
}
// app.ts
import { EcopagesApp } from '@ecopages/core/adapters/bun/create-app';
import { createBlogService } from './src/services/blog.service';
import { postAdapter } from './src/adapters/post.adapter';
import { createPostsHandlers } from './src/handlers/posts';
import { db } from './src/db';
import appConfig from './eco.config';
 
const app = new EcopagesApp({ appConfig });
 
const blogService = createBlogService(db);
const posts = createPostsHandlers(blogService, postAdapter);
 
app.get('/posts', posts.index);
app.get('/posts/:slug', posts.show);
app.post('/api/posts', posts.createDraft);
app.post('/api/posts/:slug/publish', posts.publish);
 
await app.start();

Pros & Cons

Pros:

Cons:

Mixing Patterns

You don't need to pick one pattern for your entire app. Mix patterns based on complexity:

const app = new EcopagesApp({ appConfig });
 
// Simple inline handler for about page
app.get('/about', async (ctx) => {
	const { default: AboutView } = await import('./src/views/about');
	return ctx.render(AboutView);
});
 
// Grouped handlers for posts (medium complexity)
import * as posts from './src/handlers/posts';
app.get('/posts', posts.index);
app.get('/posts/:slug', posts.show);
 
// Service layer for complex e-commerce (high complexity)
const orderService = createOrderService(db, paymentGateway, emailService);
const orders = createOrderHandlers(orderService, orderAdapter);
app.post('/api/orders', orders.create);
app.get('/api/orders/:id', orders.show);

Integration with File-System Routing

Explicit routes take precedence over file-system routes. This allows you to:

  1. Start with file-system routing
  2. Extract complex routes to explicit handlers as needed
  3. Keep simple pages in pages/ directory
// app.ts
const app = new EcopagesApp({ appConfig });
 
// Explicit routes (higher priority)
app.get('/posts', posts.index);
app.get('/posts/:slug', posts.show);
 
// File-system routes from pages/ still work
// /about -> pages/about.tsx
// /contact -> pages/contact.tsx
// /blog/[slug] -> pages/blog/[slug].tsx (if not overridden)
 
await app.start();

Best Practices

Start Simple, Refactor Later

Begin with inline handlers and refactor only when complexity demands it:

// Start here
app.get('/posts', async (ctx) => {
	const { default: PostListView } = await import('./src/views/post-list');
	const posts = await db.posts.findMany();
	return ctx.render(PostListView, { posts });
});
 
// Refactor when you need testing or reusability
const posts = createPostsHandlers(db);
app.get('/posts', posts.index);

Keep Handlers Thin

Handlers should orchestrate, not implement business logic:

async show(ctx: ApiHandlerContext) {
	const { default: PostView } = await import('../views/post');
	const post = await service.getPostBySlug(ctx.request.params.slug);
	if (!post) throw HttpError.NotFound();
	return ctx.render(PostView, adapter.toView(post));
}

Use TypeScript Types

Define clear interfaces for your dependencies:

export type BlogService = {
	getAllPublishedPosts: () => Promise<Post[]>;
	getPostBySlug: (slug: string) => Promise<Post | null>;
	createDraftPost: (data: CreatePostData) => Promise<Post>;
};
 
export function createBlogService(db: Database): BlogService {
	return {
		async getAllPublishedPosts() { },
		async getPostBySlug(slug: string) { },
		async createDraftPost(data) { }
	};
}

Separate Read and Write Models

For complex domains, consider CQRS-style separation:

export function createBlogQueries(db: Database) {
	return {
		getAllPublishedPosts: () => db.posts.findMany({ }),
		getPostBySlug: (slug: string) => db.posts.findUnique({ }),
		searchPosts: (query: string) => db.posts.search(query)
	};
}
 
export function createBlogCommands(db: Database, eventBus: EventBus) {
	return {
		createPost: async (data: CreatePostData) => {
			const post = await db.posts.create({ data });
			await eventBus.publish('post.created', post);
			return post;
		},
		publishPost: async (slug: string) => {
			const post = await db.posts.update({ where: { slug }, data: { status: 'published' } });
			await eventBus.publish('post.published', post);
			return post;
		}
	};
}

Related Documentation