Back FoxCode

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.

·12 min read

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.

The idea: a subject maintains a list of observers. When state changes, it notifies all observers automatically. Observers don't poll - they subscribe.

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:

server/plugins/audit.ts
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

The idea: A class with at most one instance, globally accessible.

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:

server/plugins/database.ts
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
})
server/utils/db.ts
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)
server/api/users.get.ts
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

The idea: Instead of calling 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:

server/lib/payment.ts
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}`)
}
server/api/checkout.post.ts
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

The idea: Constructs complex objects step by step via a fluent API. Each method returns the builder itself so you can chain. The actual object is built at the end.

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:

server/api/posts.get.ts
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

The idea: Defines a family of algorithms, encapsulates each one, and makes them interchangeable at runtime. The code that uses the algorithm doesn't need to know which one it's using.

In JavaScript, this pattern collapses to "pass a function." Which is exactly how it should be:

server/lib/auth.ts
// 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
}
server/middleware/auth.ts
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

The idea: Wraps a function or object to add behavior without modifying the original. Adds responsibility dynamically.

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:

server/utils/decorators.ts
// 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)
}
server/api/admin/users.get.ts
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 idea: Wraps an incompatible interface so it looks like the one your code expects. The most immediately practical pattern when working with third-party APIs.

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:

server/lib/adapters/email.ts
// 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 })
  }
}
server/lib/email.ts
import { ResendAdapter, SmtpAdapter } from './adapters/email'

// Swap providers with one config change
export const email = process.env.EMAIL_PROVIDER === 'smtp'
  ? new SmtpAdapter()
  : new ResendAdapter()
server/api/auth/register.post.ts
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:

server/lib/adapters/crm.ts
// 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:

PatternReal-world useIn Nuxt specifically
ObserverDaily - it's how reactivity worksVue's entire reactive system
SingletonWhen you have connections/shared resourcesDB client, Redis, logger
AdapterEvery third-party integrationWrapping Stripe, email providers, CRMs
DecoratorMiddleware, HOFs, handler compositiondefineCachedEventHandler, custom wrappers
StrategyAuth, validation, anything swappableAuth middleware, input parsers
FactoryMultiple implementations, one interfacePayment providers, notification services
BuilderMostly handled by librariesDrizzle 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