πŸ“¦
Bunty

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

Have questions? Join our Discord community
Found an issue? Edit this page on GitHub