Why Vla?
The TypeScript ecosystem has excellent frameworks for building fullstack apps: Next.js, SvelteKit, React Router, Tanstack Start, and classic options like Express and Koa. Many frameworks excel at frontend rendering, routing, and providing APIs to invoke backend code.
But there’s a gap.
Most frameworks are loose about how you structure your data layer code. They give you the freedom to organize it however you want, which sounds great until your app grows. Without clear patterns, you end up with:
- Slower development as code becomes harder to navigate
- Frequent refactoring to untangle messy dependencies
- Difficult testing with mocks scattered everywhere
- Unclear separation between business logic and data access
This is where Vla comes in. It fills the missing gap in the data layer, providing structure and conventions that make your backend code scalable, maintainable, testable, and enjoyable to work with.
- Vla works great with apps of any size.
- You can incrementally adopt Vla into existing apps. No need to rewrite everything.
- Use any framework with Vla. Vla is built to be framework agnostic.
- Write code that’s better to test.
- Vla has built-in features, like memoization to reduce the amount of unnecessary database queries.
Apps of Any Size
Section titled “Apps of Any Size”Vla scales with your project. From quick prototypes to large team-based applications.
Small Apps
Section titled “Small Apps”Start simple. Small apps can use Vla’s built-in single module to structure code into layers. You can keep everything in a single file or a handful of files.
// Everything in one placeimport { Vla } from "vla"
export class ShowDashboard extends Vla.Action { stats = this.inject(StatsService) async handle() { return this.stats.getOverview() }}
class StatsService extends Vla.Service { repo = this.inject(StatsRepo) async getOverview() { const total = await this.repo.count() return { total } }}
class StatsRepo extends Vla.Repo { db = this.inject(Database) count = this.memo(() => this.db.stats.count())}
class Database extends Vla.Resource { static readonly unwrap = "db" db = new DbClient()}As your app grows, split classes into separate files. But you can keep using the same single module.
Large Apps
Section titled “Large Apps”Split into modules. Larger apps and teams can organize code into separate modules to isolate domains from each other.
export const Users = Vla.createModule("Users")
// users/actions.tsexport class ShowUserProfile extends Users.Action { service = this.inject(UserService) async handle(userId: string) { /* ... */ }}
// users/facades.ts - Public API for other modulesexport class UserFacade extends Users.Facade { repo = this.inject(UserRepo)
async findById(id: string) { return this.repo.findById(id) }}
// billing/services.tsexport class BillingService extends Billing.Service { // ✅ Access Users module through its Facade users = this.inject(UserFacade)
async createInvoice(userId: string) { const user = await this.users.findById(userId) // ... }}Benefits for teams:
- Work independently on separate modules
- Maintain clear contracts between modules with Facades
- Organize code ownership by directories
- Prevent messy cross-module dependencies
Framework Agnostic
Section titled “Framework Agnostic”Vla is a library, not a framework. It has no built-in HTTP server, no custom build tools, no exotic integrations. This makes it easy to integrate into any framework, and keeps it compatible when frameworks release major updates.
Works Everywhere
Section titled “Works Everywhere”import { ListPosts } from "@/data/posts.actions"
export default async function PostsPage() { const posts = await ListPosts.invoke() return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>}import { ListPosts } from "$lib/data/posts.actions"
export const load = async () => { const posts = await ListPosts.invoke() return { posts }}import { ListPosts } from "@/data/posts.actions"
app.get("/api/posts", async (req, res) => { const posts = await ListPosts.invoke() res.json({ posts })})import { ListPosts } from "@/data/posts.actions"
export const listPostsFn = createServerFn() .handler(async () => { return ListPosts.invoke() })No lock-in. Because Vla doesn’t rely on custom build steps or framework-specific plugins, you won’t face breaking changes when your framework releases major updates.
Dependency Injection
Section titled “Dependency Injection”Vla uses Dependency Injection (DI) to manage how classes find and use their dependencies. Instead of manually creating instances, you declare what you need with this.inject(), and Vla handles the rest.
// ❌ Tightly coupled - hard to test, hard to changeasync function createPost(content: string) { const { user } = await getSession() ensureAccess(user, "create", "Post") await db.posts.create({ content })}With Vla’s Dependency Injection, your code depends on contracts:
// ✅ Looser coupling via dependency inversion - easy to test, easy to changeclass PostService extends Vla.Service { repo = this.inject(PostRepo) authz = this.inject(Authz)
async create(content: string) { this.authz.ensureAccess("create", "Post") return this.repo.create(content) }}No Decorators, No Reflection
Section titled “No Decorators, No Reflection”Unlike many DI frameworks, Vla intentionally avoids decorators and reflection. This keeps it:
- Simple - No magic, just straightforward TypeScript
- Type-safe - Full TypeScript inference without experimental features
- Transparent - Easy to understand and debug
Smart Dependency Rules
Section titled “Smart Dependency Rules”Vla ensures you’re not injecting things that shouldn’t be injected:
// ✅ Allowed: Actions can inject Servicesclass ShowPost extends Vla.Action { service = this.inject(PostService)}
// ✅ Allowed: Services can inject Reposclass PostService extends Vla.Service { repo = this.inject(PostRepo)}
// ✅ Allowed: Cross-module through Facadesclass BillingService extends Billing.Service { users = this.inject(UserFacade) // from Users module}
// ❌ Prevented: Repos cannot inject Services (breaks layer flow)class PostRepo extends Vla.Repo { service = this.inject(PostService) // ⛔ Error!}
// ❌ Prevented: Can't deep-inject across modulesclass BillingService extends Billing.Service { userRepo = this.inject(UserRepo) // ⛔ Error! Use UserFacade instead}Testability
Section titled “Testability”Vla makes testing easier by reducing the need for module mocks that leak file paths and folder structures into your tests. With Vla’s dependency injection, you mock at the class level, not the module level.
Before: Testing Without Vla
Section titled “Before: Testing Without Vla”// ❌ module mocks everywhereimport { vi } from "vitest"
// Mock the entire module - leaks file structurevi.mock("./repos/user-repo", () => ({ UserRepo: vi.fn().mockImplementation(() => ({ findById: vi.fn().mockResolvedValue({ id: "1", name: "Alice" }) }))}))
vi.mock("./services/billing-service", () => ({ BillingService: vi.fn().mockImplementation(() => ({ hasSubscription: vi.fn().mockResolvedValue(true) }))}))
import { UserService } from "./services/user-service"
test("gets user profile", async () => { const service = new UserService() const result = await service.getProfile("1") expect(result).toEqual({ name: "Alice", hasSubscription: true })})
// If you move files or rename them, your mocks break 💔After: Testing With Vla
Section titled “After: Testing With Vla”// ✅ mock by binding mocks into the kernelimport { Vla } from "vla"import { vi } from "vitest"import { ShowUserProfile } from "./users.actions"import { UserRepo } from "./users.repo"import { BillingFacade } from "./billing.facade"
test("gets user profile", async () => { const kernel = new Vla.Kernel()
// Mock classes, not modules - no file paths kernel.bind( UserRepo, vi.fn(class { findById = vi.fn().mockResolvedValue({ id: "1", name: "Alice" }) }) )
kernel.bind( BillingFacade, vi.fn(class { hasSubscription = vi.fn().mockResolvedValue(true) }) )
const result = await ShowUserProfile .withKernel(kernel) .invoke("1")
expect(result).toEqual({ name: "Alice", hasSubscription: true })})Testing Benefits
Section titled “Testing Benefits”- No module mocks - Test classes, not file structures
- Framework agnostic - Use any test runner (Vitest, Jest, Node’s built-in test runner)
- Easy to refactor - Move files around without breaking tests
- Clear dependencies - Mock exactly what you need at the class level
Built-in Features
Section titled “Built-in Features”Vla comes with useful features out of the box and aims to provide more over time.
Memoization for Data Access
Section titled “Memoization for Data Access”Automatically cache expensive operations within a single request. No need to manually track what’s been fetched.
class UserRepo extends Vla.Repo { db = this.inject(Database)
// Wrap with this.memo() to cache results per request findById = this.memo((id: string) => { console.log(`Fetching user ${id} from DB`) return this.db.users.find({ id }) })}
// In the same request:const user1 = await repo.findById("1") // "Fetching user 1 from DB"const user2 = await repo.findById("1") // (uses cached result, no console log)const user3 = await repo.findById("2") // "Fetching user 2 from DB"Memoized methods also support utilities:
// Force fresh data, bypass cacheconst fresh = await repo.findById.fresh("1")
// Preload cache in the backgroundrepo.findById.preload("1")
// Manually set a cached valuerepo.findById.prime("1").value({ id: "1", name: "Alice" })
// Clear cache for specific argsrepo.findById.bust("1")
// Clear all cacherepo.findById.bustAll()Stable Singletons in Dev Mode
Section titled “Stable Singletons in Dev Mode”In hot-reload dev mode, singletons like database clients can get recreated on every change, causing connection pool exhaustion. Vla’s devStable() prevents this.
class Database extends Vla.Resource { static readonly unwrap = "db"
// In dev mode, reuses the same client across hot reloads // In production, works like a normal singleton db = this.devStable("db", () => new DatabaseClient())}Request Context
Section titled “Request Context”Access request-specific data anywhere in your application without prop drilling.
// Define a context typeconst ReqContext = Vla.createContext<{ userId: string cookies: Record<string, string>}>()
// Set context in your middlewareVla.withKernel( kernel.scoped().context(ReqContext, { userId: req.cookies.userId, cookies: req.cookies }), () => next())
// Access context anywhere in your classesclass UserService extends Vla.Service { ctx = this.inject(ReqContext)
async currentUser() { const userId = this.ctx.userId // ... }}Per-Request Scoped State
Section titled “Per-Request Scoped State”Classes scoped to "invoke" (like Services and Repos) are cached per request, letting you maintain stateful data throughout a single request’s lifecycle.
class UserRepo extends Vla.Repo { // This cache is unique per request // This is a simplified reimplementation of the `this.memo()` helper private cache = new Map<string, User>()
async findById(id: string) { if (this.cache.has(id)) { return this.cache.get(id) }
const user = await this.db.users.find({ id }) this.cache.set(id, user) return user }}There’s more to come
Section titled “There’s more to come”Vla is actively developing more built-in features:
- Input validation - Validate action inputs with schema libraries
- Error handling - Unified error handling patterns
- Authorization logic - Built-in patterns for permissions and access control
- Auto tracing - Automatic request tracing and logging
- and more
Get Started
Section titled “Get Started”Ready to add structure to your backend? Check out the Getting Started guide to start using Vla in your project.