Core Concepts
Bunty uses a layered, mixed-pattern design to balance declarative setup and imperative logic, creating a maintainable modular monolith that scales with your team.
Architecture Philosophy
Bunty is built on a simple principle: Declare structure at the edges, implement behavior in the core.
This separation allows you to:
- Define what exists (routes, tables, schemas) declaratively
- Implement what happens (business logic) imperatively
- Avoid glue code and duplication
- Keep your codebase maintainable for decades
The Three Layers
1. HTTP Layer - Fluent Builder Pattern
The HTTP layer uses a fluent builder API for defining routes, middleware, and documentation:
import { createApp } from '@bunty/http';
const app = createApp();
// Declaratively define your API structure
app.route('/api/users')
.get(getUsers)
.post(createUser)
.description('User management endpoints')
.tags(['users', 'v1']);
app.route('/api/users/:id')
.get(getUser)
.put(updateUser)
.delete(deleteUser)
.validate({
params: {
id: t.Number().Positive()
}
});
// Middleware chains
app.use(cors())
.use(logger())
.use(authenticate())
.use(rateLimit());
Why fluent builders for HTTP?
- Routes are contracts - they define the API surface
- Builders make the structure immediately visible
- Documentation and validation live next to routes
- Easy to see the entire API at a glance
2. Database Layer - Fluent DSL
The database layer uses a fluent DSL that mirrors SQL semantics:
import { bunty } from "@bunty/orm/dialects/mysql/client/client";
import { bigint, varchar, timestamp } from "@bunty/orm/dialects/mysql/schema/column";
const db = bunty("mysql://localhost/mydb");
// Declaratively define your data schema
export const usersTable = db.mysqlTable("users", {
id: bigint("id", { size: 32 })
.primaryKey()
.autoIncrement()
.unsigned(),
email: varchar("email", { size: 128 })
.notNullable()
.unique(),
username: varchar("username", { size: 64 })
.notNullable()
.index(),
createdAt: timestamp("created_at")
.notNullable()
.defaultCurrentTimestamp(),
})
.charset("utf8mb4")
.collation("utf8mb4_unicode_ci")
.engine("InnoDB")
.comment("User accounts table");
Why fluent DSL for databases?
- Tables are schemas - they define data contracts
- SQL-like builders feel natural to developers
- Type safety is inferred automatically
- Easy to read, review, and migrate
3. Service Layer - Dependency Injection
The service layer contains pure business logic using classes or factories:
import { Injectable } from '@bunty/di';
import { usersTable } from '../db/schema';
@Injectable()
export class UserService {
constructor(
private db: Database,
private cache: CacheService,
private events: EventBus
) {}
async createUser(data: CreateUserDto) {
// Business logic - imperative and testable
// Validate business rules
const existing = await this.findByEmail(data.email);
if (existing) {
throw new ConflictError('Email already registered');
}
// Create user
const user = await this.db
.createQueryBuilder(usersTable)
.insert(data);
// Update cache
await this.cache.set(`user:${user.id}`, user);
// Emit event
await this.events.emit('user.created', user);
return user;
}
async findByEmail(email: string) {
// Check cache first
const cached = await this.cache.get(`user:email:${email}`);
if (cached) return cached;
// Query database
const user = await this.db
.createQueryBuilder(usersTable)
.where('email', '=', email)
.findOne();
// Cache result
if (user) {
await this.cache.set(`user:email:${email}`, user);
}
return user;
}
}
Why services for business logic?
- Business logic needs flexibility and control
- Services are testable in isolation
- Dependencies are explicit and injected
- Easy to mock, swap, and evolve
Why Mix Patterns?
Declarative Edges
Routes and schemas are contracts - they rarely change. When they do, itβs a breaking change. Declarative builders make these contracts:
β
Explicit - You can see the entire structure at a glance
β
Type-safe - Invalid definitions fail at compile time
β
Self-documenting - Structure and docs live together
β
Immutable - Hard to accidentally break contracts
Imperative Core
Business logic is behavior - it changes frequently as requirements evolve. Imperative services make logic:
β
Testable - Mock dependencies, test in isolation
β
Flexible - Add conditions, loops, early returns
β
Composable - Services call other services
β
Debuggable - Step through logic line by line
No Glue Code
By mixing patterns appropriately, you avoid common pain points:
β No repository boilerplate - Query builders are services
β No DTO mappers - Types flow through layers
β No adapter layers - Direct, clean integration
β No configuration hell - Declare what you need inline
The Bunty Mental Model
Think of your application in three parts:
βββββββββββββββββββββββββββββββββββββββ
β HTTP Layer (Declarative) β
β β’ Routes, middleware, validation β
β β’ "What endpoints exist?" β
ββββββββββββββββ¬βββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββ
β Service Layer (Imperative) β
β β’ Business logic, workflows β
β β’ "What actually happens?" β
ββββββββββββββββ¬βββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββ
β Database Layer (Declarative) β
β β’ Tables, columns, constraints β
β β’ "What data exists?" β
βββββββββββββββββββββββββββββββββββββββ
Example: User Registration
HTTP Layer - The Contract:
app.route('/auth/register')
.post(registerHandler)
.validate({
body: {
email: t.String().Email(),
password: t.String().MinLength(8),
username: t.String().MinLength(3)
}
})
.description('Register a new user account');
Service Layer - The Behavior:
@Injectable()
class AuthService {
async register(data: RegisterDto) {
// Business logic
await this.validatePassword(data.password);
const hashedPassword = await this.hashPassword(data.password);
const user = await this.userService.create({
...data,
password: hashedPassword
});
const token = await this.generateToken(user);
await this.sendWelcomeEmail(user);
return { user, token };
}
}
Database Layer - The Schema:
const usersTable = db.mysqlTable("users", {
id: bigint("id").primaryKey().autoIncrement(),
email: varchar("email", { size: 128 }).notNullable().unique(),
username: varchar("username", { size: 64 }).notNullable().unique(),
password: varchar("password", { size: 255 }).notNullable().hidden(),
});
Benefits of This Design
1. Maintainability
- Clear separation of concerns - Each layer has one job
- Easy to find code - Structure mirrors architecture
- Predictable changes - Know exactly what breaks when
2. Testability
- Mock at layer boundaries - Test services without HTTP or DB
- Isolated unit tests - Each service tests one thing
- Integration tests - Test full request β response flow
3. Scalability
- Horizontal scaling - Stateless services scale easily
- Team scaling - Teams own services, not layers
- Code scaling - Monolith can split into services if needed
4. Developer Experience
- Fast feedback - Types catch errors before runtime
- Clear contracts - Know exactly what each endpoint does
- Easy onboarding - Consistent patterns everywhere
- Joy to work with - Less boilerplate, more features
Modular Monolith
Bunty encourages a modular monolith architecture:
src/
βββ modules/
β βββ auth/
β β βββ routes.ts # HTTP layer
β β βββ services.ts # Service layer
β β βββ schema.ts # Database layer
β βββ users/
β β βββ routes.ts
β β βββ services.ts
β β βββ schema.ts
β βββ posts/
β βββ routes.ts
β βββ services.ts
β βββ schema.ts
βββ shared/
βββ middleware/
βββ utils/
βββ types/
Each module:
- β Has clear boundaries
- β Can be tested independently
- β Can be extracted to a microservice later
- β Owns its data, routes, and logic
Composition Over Inheritance
Bunty favors composition for code reuse:
@Injectable()
class UserService {
constructor(
private db: Database,
private validator: ValidationService, // Compose
private notifier: NotificationService, // Compose
private logger: LoggerService // Compose
) {}
async createUser(data: CreateUserDto) {
// Use composed services
await this.validator.validate(data, userSchema);
const user = await this.db.insert(usersTable, data);
await this.notifier.sendWelcome(user);
this.logger.info('User created', { userId: user.id });
return user;
}
}
No complex inheritance hierarchies. Just small, focused services composed together.
Type Safety Throughout
Types flow through all layers automatically:
// Define schema
const usersTable = db.mysqlTable("users", {
id: bigint("id").primaryKey(),
email: varchar("email").notNullable(),
username: varchar("username").notNullable(),
});
// Type is inferred automatically!
type User = typeof usersTable.$inferSelect;
// { id: bigint, email: string, username: string }
type NewUser = typeof usersTable.$inferInsert;
// { email: string, username: string } (id is auto-generated)
// Use in services
class UserService {
async findById(id: User['id']): Promise<User | null> {
// Fully typed!
}
async create(data: NewUser): Promise<User> {
// Fully typed!
}
}
Best Practices
β Do
- Keep HTTP layer declarative - just routes and validation
- Put business logic in services - keep it testable
- Define schemas once - let types flow through
- Use dependency injection - make dependencies explicit
- Write tests for services - they contain critical logic
- Keep modules focused - one responsibility each
β Donβt
- Put business logic in route handlers
- Bypass the service layer to query directly
- Duplicate validation logic across layers
- Use singletons for stateful services
- Mix data access with business logic
- Create circular dependencies between modules
Goal: Decades of Maintainability
Bunty is designed for long-term maintenance:
- Clear patterns reduce cognitive load for new developers
- Type safety prevents entire classes of bugs
- Testability enables confident refactoring
- Modularity allows gradual evolution
- Composition keeps code flexible
Build software that lasts, with a framework that gets out of your way.
π― The Bunty Way
Declarative edges define contracts. Imperative services implement behavior. Mix patterns intentionally. Stay composable, testable, and maintainable for the long haul.
Next Steps
- Build your first HTTP API
- Set up Dependency Injection
- Learn about Database schemas