Ecopages0.2.0-rc.4

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/config-builder';
import { ecopagesJsxPlugin } from '@ecopages/ecopages-jsx';
 
export default await new ConfigBuilder()
  .setBaseUrl('http://localhost:3000')
  .setIntegrations([ecopagesJsxPlugin()])
  .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

  • Small projects (< 10 routes)
  • Rapid prototyping
  • Simple CRUD operations
  • No shared business logic

Example

import { createApp } from '@ecopages/core/create-app';
import { HttpError } from '@ecopages/core/errors';
import { db } from './src/db';
import appConfig from './eco.config';
 
const app = await createApp({ 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.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:

  • Quick to write
  • Easy to understand
  • No file navigation needed

Cons:

  • Hard to test without running the full app
  • Becomes unwieldy with many routes
  • No code reuse between routes

Pattern B: Grouped Handlers

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

When to Use

  • Medium-sized projects (10-50 routes)
  • Clear resource boundaries (posts, users, comments)
  • Multiple developers working on different features
  • Need better organization than inline

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.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.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.params;
	await db.posts.delete({ where: { slug } });
	return ctx.json({ deleted: true });
}
// app.ts
import { createApp } from '@ecopages/core/create-app';
import * as posts from './src/handlers/posts';
import * as users from './src/handlers/users';
import appConfig from './eco.config';
 
const app = await createApp({ 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:

  • Clear separation of concerns
  • Easy to locate handler code
  • Simple refactoring from inline
  • Multiple developers can work in parallel

Cons:

  • Still couples handlers to database
  • Testing requires mocking database
  • No dependency injection

Pattern C: Factory Functions

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

When to Use

  • Testing is a priority
  • Multiple environments (dev, staging, prod with different configs)
  • Want to swap implementations (real DB vs in-memory)
  • Building libraries or reusable modules

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.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 { createApp } from '@ecopages/core/create-app';
import { createPostsHandlers } from './src/handlers/posts';
import { db } from './src/db';
import appConfig from './eco.config';
 
const app = await createApp({ 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:

  • Excellent testability without mocks
  • Clear dependencies
  • Easy to swap implementations
  • No hidden global state

Cons:

  • More boilerplate
  • Requires understanding of closures
  • Can feel over-engineered for simple apps

Pattern D: MVC Classes

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

When to Use

  • Team prefers object-oriented style
  • Coming from frameworks like NestJS, Laravel, ASP.NET
  • Want to use decorators (future)
  • Need instance state or lifecycle hooks

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.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.params;
		const post = await this.postsService.update(slug, ctx.body);
		return ctx.json(post);
	}
 
	async destroy(ctx: ApiHandlerContext) {
		const { slug } = ctx.params;
		await this.postsService.delete(slug);
		return ctx.json({ deleted: true });
	}
}
// app.ts
import { createApp } from '@ecopages/core/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 = await createApp({ 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:

  • Familiar to MVC developers
  • Clear separation: controller → service → database
  • Easy to add lifecycle hooks
  • Good for large teams with OOP background

Cons:

  • More boilerplate than functions
  • Requires wrapper lambdas in routes
  • this binding adds instance-method overhead
  • Heavier mental model

Pattern E: Service Layer

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

When to Use

  • Complex business domains
  • Multiple presentation formats (web, API, CLI)
  • Need to reuse business logic across handlers
  • Want domain-driven design (DDD)

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.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.params;
			const post = await service.publishPost(slug);
			if (!post) throw HttpError.NotFound('Post not found');
			return ctx.json(post);
		}
	};
}
// app.ts
import { createApp } from '@ecopages/core/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 = await createApp({ 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:

  • Maximum separation of concerns
  • Business logic is framework-agnostic
  • Easy to reuse across different interfaces
  • Excellent for complex domains
  • Adapters make views independent of database schema

Cons:

  • Most boilerplate of all patterns
  • Can be over-engineering for simple apps
  • More files to navigate
  • Steeper learning curve

Mixing Patterns

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

const app = await createApp({ 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 = await createApp({ 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.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