Design patterns in 2026 vs Nuxt
Factory, Singleton, Observer, Builder, Strategy, Decorator, Adapter - seven classic patterns, honest verdict on each, and real examples in Nuxt and Nitro.
Vue's reactivity system is the Observer pattern. defineCachedEventHandler is a Decorator. Drizzle ORM's .where().orderBy().limit() is a Builder.
Some of these are worth knowing by name and reaching for deliberately. Others are interview vocabulary - frameworks solved them for you already.
Observer
The most important pattern in frontend development.
Vue's entire reactivity system is Observer under the hood. When you write const count = ref(0) and use it in a template, Vue registers the template render function as an observer of count. When count.value++ happens, Vue notifies all registered observers and triggers re-renders. watch, watchEffect, computed - all of them are Observer implementations.
// This is Observer.
const user = ref<User | null>(null)
// Registers "send analytics" as an observer of `user`
watch(user, (newUser) => {
if (newUser) analytics.identify(newUser.id)
})
Nuxt exposes its own event system via Nitro hooks, which is Observer at the server level:
export default defineNitroPlugin((nitro) => {
nitro.hooks.hook('request', (event) => {
// Observes every incoming request
console.log(`[${new Date().toISOString()}] ${event.method} ${event.path}`)
})
})
Node's EventEmitter - which backs a massive chunk of the Node ecosystem - is Observer. The pattern predates JavaScript by decades (it's from the original GoF book, 1994) but became the dominant metaphor for async programming precisely because it maps perfectly to "something happened, react to it."
Vue's entire reactive system runs on this. Every watch, computed, and ref is Observer in practice.
Singleton
Global mutable state is how bugs hide. But Singleton has a completely legitimate use case - connections and shared resources that are expensive to create and stateful by nature. Database clients, Redis connections, logger instances, etc.
In Nuxt, the correct pattern is to initialize these once in a server plugin and expose them via a utility function:
import { drizzle } from 'drizzle-orm/better-sqlite3'
import Database from 'better-sqlite3'
// One connection, created once, reused for the lifetime of the server
const sqlite = new Database('sqlite.db')
const db = drizzle(sqlite)
// Expose via a function so callers don't hold a reference to the module
export default defineNitroPlugin(() => {
// db is effectively a singleton - initialized once, shared everywhere
})
import { drizzle } from 'drizzle-orm/better-sqlite3'
import Database from 'better-sqlite3'
// In Nitro, top-level module state IS a singleton
// This runs once per server process
const sqlite = new Database(process.env.DB_PATH!)
export const db = drizzle(sqlite)
import { db } from '../utils/db'
export default defineEventHandler(async () => {
return db.select().from(users).all()
})
useNuxtApp() on the client is also a Singleton - it returns the same Nuxt app instance no matter where you call it.
Good for DB connections, Redis clients, loggers - shared resources that are expensive to initialize. Avoid it for general application state.
Factory
new Thing() directly, you call a function that decides what to create and returns it. The caller doesn't need to know the concrete type.This sounds abstract until you have multiple implementations of the same interface. Payment providers are the classic example:
interface PaymentProvider {
charge(amount: number, currency: string, token: string): Promise<{ id: string }>
refund(chargeId: string): Promise<void>
}
class StripeProvider implements PaymentProvider {
async charge(amount: number, currency: string, token: string) {
const stripe = new Stripe(process.env.STRIPE_KEY!)
const charge = await stripe.paymentIntents.create({ amount, currency, payment_method: token, confirm: true })
return { id: charge.id }
}
async refund(chargeId: string) {
const stripe = new Stripe(process.env.STRIPE_KEY!)
await stripe.refunds.create({ payment_intent: chargeId })
}
}
class MollieProvider implements PaymentProvider {
async charge(amount: number, currency: string, token: string) {
// mollie implementation
return { id: 'mollie_123' }
}
async refund(chargeId: string) { /* ... */ }
}
// Factory - decides which provider based on config
export function createPaymentProvider(): PaymentProvider {
const provider = process.env.PAYMENT_PROVIDER ?? 'stripe'
if (provider === 'stripe') return new StripeProvider()
if (provider === 'mollie') return new MollieProvider()
throw new Error(`Unknown payment provider: ${provider}`)
}
import { createPaymentProvider } from '../lib/payment'
export default defineEventHandler(async (event) => {
const { amount, token } = await readBody(event)
const payment = createPaymentProvider() // doesn't know or care which one
return await payment.charge(amount, 'eur', token)
})
You see this pattern in frameworks constantly. defineEventHandler, defineNuxtPlugin, defineNuxtRouteMiddleware - all of these are factory functions. They configure the instance based on context.
Worth reaching for when you genuinely have multiple implementations behind the same interface. Make this decision once when you set up the module - the rest of the code stays ignorant of which implementation is running.
Builder
You use this constantly and probably don't think about it as a pattern:
// Drizzle ORM is a Builder
const results = await db
.select({ id: users.id, name: users.name })
.from(users)
.where(eq(users.active, true))
.orderBy(desc(users.createdAt))
.limit(20)
.offset(page * 20)
// @nuxt/content queryCollection is a Builder
const posts = await queryCollection('blog')
.where('tags', 'LIKE', `%${tag}%`)
.order('date', 'DESC')
.limit(10)
.find()
The value is that you can conditionally add steps without nested ternaries:
export default defineEventHandler(async (event) => {
const { tag, author, limit = 20, page = 0 } = getQuery(event)
let query = queryCollection('blog').order('date', 'DESC')
// Conditionally add filters - much cleaner than building SQL strings
if (tag) query = query.where('tags', 'LIKE', `%${tag}%`)
if (author) query = query.where('author', '==', author)
return query.limit(Number(limit)).skip(Number(page) * Number(limit)).find()
})
Writing your own Builder makes sense when you're constructing something complex - an email template, a PDF, a search query with many optional filters. Writing it from scratch just for the pattern is overkill.
We are already using this through ORMs and query libraries. Rolling own makes sense for a genuinely complex object with many optional parts - overkill for two optional parameters.
Strategy
In JavaScript, this pattern collapses to "pass a function." Which is exactly how it should be:
// Instead of a Strategy class hierarchy, just type the function
type AuthStrategy = (event: H3Event) => Promise<User | null>
const jwtStrategy: AuthStrategy = async (event) => {
const token = getHeader(event, 'authorization')?.replace('Bearer ', '')
if (!token) return null
return verifyJwt(token)
}
const sessionStrategy: AuthStrategy = async (event) => {
const sessionId = getCookie(event, 'session_id')
if (!sessionId) return null
return sessionStore.get(sessionId)
}
const apiKeyStrategy: AuthStrategy = async (event) => {
const key = getHeader(event, 'x-api-key')
if (!key) return null
return db.select().from(apiKeys).where(eq(apiKeys.key, key)).get()?.user ?? null
}
import { jwtStrategy, sessionStrategy, apiKeyStrategy } from '../lib/auth'
// Strategies tried in order - first one that returns a user wins
const strategies = [jwtStrategy, sessionStrategy, apiKeyStrategy]
export default defineEventHandler(async (event) => {
for (const strategy of strategies) {
const user = await strategy(event)
if (user) {
event.context.user = user
return
}
}
// No strategy matched - user is unauthenticated
})
This is Strategy, just an array of functions with the same signature. You can add a new auth method by adding a function to the array. You can test each strategy in isolation. The middleware doesn't care which one works.
Genuinely useful - especially for auth and anything where you want swappable behavior.
Decorator
This one is everywhere in backend code. Nitro's defineCachedEventHandler is a Decorator - it takes a handler and returns a new handler that caches the result:
// defineCachedEventHandler is a decorator built into Nitro
export default defineCachedEventHandler(async (event) => {
return await db.select().from(products).all()
}, { maxAge: 300, name: 'products' })
You can write your own for cross-cutting concerns - logging, timing, auth checks:
// Adds timing to any handler
export function withTiming(handler: EventHandler): EventHandler {
return defineEventHandler(async (event) => {
const start = performance.now()
const result = await handler(event)
const duration = performance.now() - start
setResponseHeader(event, 'X-Response-Time', `${duration.toFixed(2)}ms`)
return result
})
}
// Requires authentication
export function withAuth(handler: EventHandler): EventHandler {
return defineEventHandler(async (event) => {
if (!event.context.user) {
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
return handler(event)
})
}
// Combine multiple decorators
export function pipe(...decorators: Array<(h: EventHandler) => EventHandler>) {
return (handler: EventHandler) => decorators.reduceRight((h, d) => d(h), handler)
}
import { withAuth, withTiming, pipe } from '../utils/decorators'
const handler = defineEventHandler(async () => {
return db.select().from(users).all()
})
// Apply both decorators - clean, composable, no modification to handler
export default pipe(withAuth, withTiming)(handler)
TypeScript also has a first-class @decorator syntax (now stage 3), though it's more common in NestJS than Nuxt:
// NestJS style - you'll see this in enterprise TS backends
@Controller('users')
@UseGuards(AuthGuard)
export class UsersController {
@Get()
@CacheKey('all-users')
findAll() { /* ... */ }
}
Very useful for Nitro middleware. defineCachedEventHandler is already this pattern. Good for example for stop copy-pasting auth checks into every route.
Adapter
The problem it solves: you integrate Stripe, your code now depends on Stripe's interface everywhere. When Stripe changes their API (or you want to add a second provider, or you want to test without hitting the real API), you have to touch every file that calls Stripe directly.
An Adapter puts a layer between your code and theirs:
// Your interface - stable, controlled by you
export interface EmailService {
send(opts: { to: string; subject: string; html: string }): Promise<void>
}
// Adapter for Resend
export class ResendAdapter implements EmailService {
private client: Resend
constructor() {
this.client = new Resend(process.env.RESEND_API_KEY!)
}
async send({ to, subject, html }: { to: string; subject: string; html: string }) {
await this.client.emails.send({
from: '[email protected]',
to,
subject,
html,
// Resend has different field names, extra options - adapter handles that
})
}
}
// Adapter for Nodemailer (SMTP - legacy systems)
export class SmtpAdapter implements EmailService {
private transporter: nodemailer.Transporter
constructor() {
this.transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
})
}
async send({ to, subject, html }: { to: string; subject: string; html: string }) {
await this.transporter.sendMail({ from: process.env.SMTP_FROM, to, subject, html })
}
}
import { ResendAdapter, SmtpAdapter } from './adapters/email'
// Swap providers with one config change
export const email = process.env.EMAIL_PROVIDER === 'smtp'
? new SmtpAdapter()
: new ResendAdapter()
import { email } from '../../lib/email' // doesn't know or care which provider
export default defineEventHandler(async (event) => {
const user = await createUser(await readBody(event))
// Works with any EmailService implementation
await email.send({
to: user.email,
subject: 'Welcome',
html: `<p>Hi ${user.name}</p>`,
})
return { success: true }
})
The same pattern for legacy REST APIs - when you're integrating a third-party service with an awkward or verbose API surface:
// The CRM API returns objects like { ContactData: { first_name_field: '...' } }
// Your code wants { name: string, email: string }
export class HubSpotAdapter {
async getContact(id: string): Promise<{ name: string; email: string }> {
const raw = await $fetch(`https://api.hubapi.com/contacts/v1/contact/vid/${id}/profile`, {
headers: { Authorization: `Bearer ${process.env.HUBSPOT_TOKEN}` },
})
// Translate their weird shape to yours
return {
name: `${raw.properties.firstname.value} ${raw.properties.lastname.value}`,
email: raw.properties.email.value,
}
}
}
Probably the most practically useful one here. Very good when you integrate a third-party API - 15 minutes now, a lot of pain avoided when their API changes or you switch providers.
So do you actually use these?
Breakdown for a Nuxt fullstack developer:
| Pattern | Real-world use | In Nuxt specifically |
|---|---|---|
| Observer | Daily - it's how reactivity works | Vue's entire reactive system |
| Singleton | When you have connections/shared resources | DB client, Redis, logger |
| Adapter | Every third-party integration | Wrapping Stripe, email providers, CRMs |
| Decorator | Middleware, HOFs, handler composition | defineCachedEventHandler, custom wrappers |
| Strategy | Auth, validation, anything swappable | Auth middleware, input parsers |
| Factory | Multiple implementations, one interface | Payment providers, notification services |
| Builder | Mostly handled by libraries | Drizzle queries, queryCollection |
The patterns you won't explicitly write: usually Builder (ORM does it), and Factory unless you have a legitimate "which implementation?" problem.
On interview questions
The expected answer is usually: name the pattern, describe the problem it solves, give an example.
The more honest question to ask in return: "Can you show me an example in your codebase?" Real production code almost never has a class ConcreteObserver extends AbstractObserver comment. It just has a watch() or an event emitter or a subscription. The pattern is there. The vocabulary isn't.
What's actually worth knowing: the problems each pattern solves, not the canonical class diagram from 1994. If you understand that Adapter solves "I don't want to be coupled to this third-party interface" and Strategy solves "I need to swap algorithms at runtime" - you'll reach for the right pattern when the problem appears, with or without naming it.
Most of the time in Nuxt you're already using these - just not naming them. Where naming helps: Adapter when integrating third-party APIs, Strategy when you find yourself copy-pasting auth logic across handlers, Decorator when you want cross-cutting behavior without modifying every route.
The Gang of Four book is still worth reading for the problem descriptions - they've aged well. The Java class hierarchies haven't, but recognizing the problem when you see it is useful regardless.
Continue Reading
It's just an array
Stacks, queues, heaps, trees - most reduce to arrays, numbers, and pointers. Here's what actually happens in memory.
Frontend internals: 90 concepts
Hydration, fiber, QUIC, CRDTs, compositing, INP - from Vue perspective