Architecture
Guren reimagines Laravel's design principles in TypeScript, tying together Bun, Hono, Inertia.js, React, and Drizzle ORM into a full-stack MVC framework. This document outlines the end-to-end flow from routing to response generation and explains the main building blocks.
Architecture
Guren reimagines Laravel's design principles in TypeScript, tying together Bun, Hono, Inertia.js, React, and Drizzle ORM into a full-stack MVC framework. This document outlines the end-to-end flow from routing to response generation and explains the main building blocks.
High-Level Flow
- Container & Providers:
ServiceProviderclasses register services into the IoC container during boot. - Routing: Define routes in
routes/web.tswith a registrar that receives an app-localRouter. - Controllers: Extend the
Controllerbase class with dependency injection viastatic inject. - Models: Extend
Model<TRecord>and link to a Drizzle schema viastatic table. - Views: Render React components (
resources/js/pages/) through Inertia.js. - Application boot:
Applicationinitialises the container, runs providers, mounts routes, and starts the Bun/Hono server.
Project Layout
app/Http/Controllers/: Home for your controllers.app/Http/Requests/: Optional legacyFormRequestclasses for compatibility-heavy apps.app/Models/: Drizzle-backed models extendingModel<T>.app/Providers/: ServiceProvider classes for registering services.app/Events/: Event classes.app/Listeners/: Event listener classes.app/Jobs/: Queue job classes.config/: Application and database configuration files.db/: Schema definitions, migrations, and seeders.resources/js/pages/: React pages rendered via Inertia.routes/: Route declarations (routes/web.ts).src/: Application bootstrapping (src/main.ts,src/app.ts).
Naming Conventions
- Files that export a single class or type (e.g. controllers, models, HTTP application) use
PascalCase.tsso the filename mirrors the exported identifier. - Utility modules that gather functions or helpers use
kebab-case.ts(for exampledev-assets.ts,inertia-assets.ts) to distinguish them from class-centric modules. - Each directory sticks to one convention; when you add a new file under
app/Http/Controllers/orapp/Models/, prefer PascalCase for new classes, while helper-heavy folders should stay in kebab-case.
Service Container
The IoC container is the heart of Guren's architecture. It manages service registration, resolution, and dependency injection throughout the application lifecycle.
Binding Services
import { ServiceProvider } from '@guren/core'
import { CacheManager } from '@guren/core'
export default class CacheProvider extends ServiceProvider {
register(): void {
this.container.singleton('cache', () => {
return new CacheManager({ driver: 'memory' })
})
}
boot(): void {
// Runs after all providers have registered
const cache = this.container.make<CacheManager>('cache')
cache.initialize()
}
}
Resolving Services
// Type-safe resolution
const events = container.make<EventManager>('events')
const cache = container.make<CacheManager>('cache')
// Testing: replace a service with a fake
container.fake('events', mockEventManager)
Facades
Facades provide a static proxy to services resolved from the container. They offer a convenient, expressive syntax without sacrificing testability:
import { createFacades } from '@guren/core'
const { Cache, Events } = createFacades(app.container)
// Use anywhere without manually resolving from the container
await Cache.get('user:1')
await Cache.put('user:1', userData, 3600)
Events.dispatch(new UserRegistered(user))
Service Providers
All providers extend ServiceProvider and implement register() and optionally boot():
import { ServiceProvider } from '@guren/core'
export default class AppServiceProvider extends ServiceProvider {
register(): void {
// Bind services into the container
this.container.singleton('myService', () => new MyService())
}
boot(): void {
// Runs after ALL providers have called register()
// Safe to resolve other services here
const db = this.container.make<Database>('db')
}
}
register(): Bind services into the container. Do not resolve other services here—they may not be registered yet.boot(): Called after every provider'sregister()has run. Safe to resolve and use other services.
Global middleware must be attached in
register(). Routes are mounted betweenregister()andboot(), and Hono only applies middleware registered ahead of the matched route —app.use()fromboot()never runs for your routes. Both hooks may beasyncif you need to load resources (e.g. translation files) first:export default class I18nProvider extends ServiceProvider { async register(): Promise<void> { const i18n = createI18n({ locale: 'ja', fallbackLocale: 'en', path: './lang' }) await i18n.loadLocales(['en', 'ja']) setI18n(i18n) const app = this.container.make<Application>('app') app.use('*', localeMiddleware) // register(), not boot() } }
Routing
routes/web.ts exports a registrar and configures an app-local router:
import { Router, requireAuthenticated } from '@guren/core'
import PostController from '@/app/Http/Controllers/PostController'
export function registerWebRoutes(router: Router): void {
router.aliasMiddleware('auth', requireAuthenticated())
router.bind('post', Post)
router.get('/', [PostController, 'index'])
router.middleware('auth').group((auth) => {
auth.get('/posts/:post', [PostController, 'show'])
})
}
- Each
Applicationowns its own router instance, so tests and multiple apps do not share route state. - Controllers are referenced with the
[Class, 'method']tuple. - Route model binding automatically resolves model instances from route parameters.
Controllers
Controllers extend Controller and support dependency injection via static inject. Methods return responses through helpers like this.inertia(), this.json(), this.created(), or this.noContent().
import { Controller, paginate, type PaginatedPageProps } from '@guren/core'
import { PostResource, type PostResourceData } from '@/app/Http/Resources/PostResource'
import { pages } from '@/.guren/pages.gen'
type PostsIndexProps = PaginatedPageProps<PostResourceData>
export default class PostController extends Controller {
static inject = ['cache'] as const
constructor(private cache: CacheManager) {
super()
}
async index() {
const result = await Post.where('status', 'published').paginate({ page: 1, perPage: 15 })
const paginator = paginate(result, { path: this.request.path ?? '/posts' })
return this.inertia<PostsIndexProps>(pages.posts.Index, {
data: result.data.map((post) => new PostResource(post).toJSON()),
pagination: paginator,
})
}
async store() {
const data = await this.validateBody(StorePostSchema)
const post = await Post.create(data)
return this.created({ post })
}
}
this.ctx: Full Hono context.this.request: Convenience accessor for the underlying Request.this.inertia(component, props, options): Creates an Inertia response.this.validateBody(schema): Validates and returns typed request data.await this.input('key'),this.query('key'): Input helpers.
Models and ORM
Models typically use defineModel(table) and connect to Drizzle via the table definition itself. The layer provides a fluent QueryBuilder, scopes, hooks, soft deletes, attribute casting, and mass assignment protection.
export type PostRecord = typeof posts.$inferSelect
export class Post extends defineModel(posts) {
static fillable = ['title', 'content', 'status']
static casts = { metadata: 'json', publishedAt: 'date' }
static scopes = {
published: (q: QueryBuilder<PostRecord>) => q.where('status', 'published'),
}
static hooks = {
creating: async (data) => { data.slug = slugify(data.title) },
}
}
- Provides Laravel-style helpers like
Model.all(),Model.find(id),Model.findOrFail(),Model.first(), andModel.create(data). - Fluent QueryBuilder:
Post.where('status', 'published').orderBy('createdAt', 'desc').limit(10).get(). - Table inference keeps static helpers strongly typed (e.g.
Post.find()returnsPostRecord | null). - Use a provider such as
DatabaseProvider(which callsbootModels()) to invokeDrizzleAdapter.configure(db), making the adapter available to every model. When you need full control, useModel.query(db)or the Drizzle database instance directly.
Inertia.js and Views
- Place React pages under
resources/js/pages/and reference them by component name. - The server embeds the Inertia payload inside HTML via the
data-pageattribute. - The client loads React/Inertia from CDN ESM modules and hydrates the initial page.
Bootstrapping the Application
src/main.ts in a generated project illustrates the boot process:
- Export a route registrar from
routes/web.ts. - Instantiate the application with providers and routes:
const app = createApp({ routes: registerWebRoutes, providers: [DatabaseProvider, AuthProvider, EventServiceProvider], }) await app.boot()to mount routes, run provider register/boot hooks, and prepare middleware.await app.listen()to start the HTTP server.
This process runs under Bun as a native module and is triggered by bun run dev.
Database Schema
- Drizzle schema definitions live in
db/schema.ts. config/database.tsprovisions tables when the container starts.- Migrations are managed via
bunx guren make:migrationand applied withbun run db:migrate.
Request Lifecycle
- Hono receives an HTTP request.
- Global middleware runs (session, CSRF, rate limiting, etc.).
- The application router resolves the matching handler and runs route-level middleware.
- Route model binding resolves any bound parameters.
- The controller is instantiated with injected dependencies.
- The controller method executes, using models to access the database.
this.inertia()hands data to the view and builds the Inertia response.- The client hydrates React on the first load; subsequent navigation stays within Inertia-powered SPA transitions.
For a deeper dive into internals, explore the CLI Reference and the inline documentation within your generated project.