Fr

Cruddy by Design Patterns

This podcast app demonstrates four key REST API design patterns from Adam Wathan's "Cruddy by Design" talk, translated from Laravel to Nuxt with Nuxt Content.

1

Nested Resources Get Dedicated Routes

Instead of treating nested resources as actions on the parent, give them their own dedicated routes and controllers.

Problem
Coupling nested resources to parent controllers leads to bloated, unfocused code.
Solution
Episodes are a first-class resource with their own dedicated endpoints.

List episodes for podcast

/api/podcasts/[podcastId]/episodes/index.get.ts
// Dedicated route for listing episodes
// GET /api/podcasts/full-stack-radio/episodes
export default defineEventHandler(async (event) => {
  const podcastId = getRouterParam(event, 'podcastId')
  const episodes = await queryCollection(event, 'examplesEpisodes')
    .where('podcastSlug', podcastId)
    .where('status', 'published')
    .all()
  return episodes
})

Get single episode

/api/podcasts/[podcastId]/episodes/[episodeId].get.ts
// Dedicated route for single episode
// GET /api/podcasts/full-stack-radio/episodes/episode-1
export default defineEventHandler(async (event) => {
  const episodeId = getRouterParam(event, 'episodeId')
  const episode = await queryCollection(event, 'examplesEpisodes')
    .where('slug', episodeId)
    .first()
  return episode
})
2

Properties Edited Independently Become Separate Resources

When a property needs to be managed independently from its parent entity, model it as its own resource with dedicated CRUD operations.

Problem
Mixing property-specific logic with general entity updates creates coupling and complexity.
Solution
Cover images and playback progress have their own resource endpoints.

Update cover image

/api/podcasts/[podcastId]/cover-image.put.ts
// Independent resource for cover image updates
// PUT /api/podcasts/full-stack-radio/cover-image
export default defineEventHandler(async (event) => {
  const podcastId = getRouterParam(event, 'podcastId')
  const formData = await readMultipartFormData(event)
  const imageFile = formData?.find(field => field.name === 'image')

  // Store image and update podcast frontmatter
  const imagePath = await saveImage(imageFile)
  await updatePodcastFrontmatter(podcastId, { coverImage: imagePath })

  return { coverImage: imagePath }
})

Update playback progress

/api/podcasts/playback-progress/[episodeSlug].put.ts
// Progress is independent from episode content
// PUT /api/podcasts/playback-progress/episode-1
export default defineEventHandler(async (event) => {
  const episodeSlug = getRouterParam(event, 'episodeSlug')
  const { position, completed } = await readBody(event)

  await db.insert(playbackProgress)
    .values({ userId, episodeSlug, position, completed })
    .onConflictDoUpdate({ set: { position, completed } })

  return { success: true }
})
3

Pivot Models Are Their Own Resource

Instead of treating many-to-many relationships as actions on one side, model the pivot table as its own resource with full CRUD operations.

Problem
Treating subscriptions as /podcasts/[id]/subscribe creates asymmetry and limits functionality.
Solution
Subscriptions are a first-class resource with list, create, and delete operations.

List subscriptions

/api/podcasts/subscriptions/index.get.ts
// Subscriptions as a resource, not an action
// GET /api/podcasts/subscriptions
export default defineEventHandler(async (event) => {
  const userId = await getUserId(event)
  const subscriptions = await db
    .select()
    .from(subscriptionsTable)
    .where(eq(subscriptionsTable.userId, userId))
  return subscriptions
})

Create subscription

/api/podcasts/subscriptions/index.post.ts
// POST /api/podcasts/subscriptions
export default defineEventHandler(async (event) => {
  const { podcastSlug } = await readBody(event)
  const userId = await getUserId(event)

  const subscription = await db.insert(subscriptionsTable)
    .values({ userId, podcastSlug })
    .returning()

  return subscription[0]
})

Delete subscription

/api/podcasts/subscriptions/[id].delete.ts
// DELETE /api/podcasts/subscriptions/123
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')
  await db.delete(subscriptionsTable)
    .where(eq(subscriptionsTable.id, id))
  return { success: true }
})
4

Different States as Different Resources

Instead of filtering by status or using state transition actions, model different states as separate resources with their own endpoints.

Problem
State transitions as actions (publish, archive) obscure the underlying CRUD operations.
Solution
Draft and published episodes are separate resources. Publishing is creating a published-episode; unpublishing is deleting it.

List draft episodes

/api/podcasts/[podcastId]/draft-episodes/index.get.ts
// Draft episodes as a resource
// GET /api/podcasts/full-stack-radio/draft-episodes
export default defineEventHandler(async (event) => {
  const podcastId = getRouterParam(event, 'podcastId')
  const drafts = await queryCollection(event, 'examplesEpisodes')
    .where('podcastSlug', podcastId)
    .where('status', 'draft')
    .all()
  return drafts
})

Publish episode (create published-episode)

/api/podcasts/published-episodes/index.post.ts
// Publishing = creating a published-episode resource
// POST /api/podcasts/published-episodes
export default defineEventHandler(async (event) => {
  const { episodeSlug } = await readBody(event)

  // Update status in markdown frontmatter
  await updateEpisodeFrontmatter(episodeSlug, { status: 'published' })

  return { success: true }
})

Unpublish episode (delete published-episode)

/api/podcasts/published-episodes/[id].delete.ts
// Unpublishing = deleting a published-episode resource
// DELETE /api/podcasts/published-episodes/episode-1
export default defineEventHandler(async (event) => {
  const episodeSlug = getRouterParam(event, 'id')

  // Update status in markdown frontmatter
  await updateEpisodeFrontmatter(episodeSlug, { status: 'draft' })

  return { success: true }
})

In-progress episodes as a resource

/api/podcasts/in-progress-episodes.get.ts
// Playback state as a filterable resource
// GET /api/podcasts/in-progress-episodes
export default defineEventHandler(async (event) => {
  const userId = await getUserId(event)
  const progress = await db.select()
    .from(playbackProgressTable)
    .where(and(
      eq(playbackProgressTable.userId, userId),
      gt(playbackProgressTable.position, 0),
      eq(playbackProgressTable.completed, false)
    ))
  return progress
})

Key Takeaways

  • Think in resources, not actions. Every URL should represent a resource (noun), not an action (verb).
  • Embrace standard CRUD operations. Store (POST), Show (GET), Update (PUT), Destroy (DELETE) cover most use cases.
  • Create new resources instead of custom actions. Publishing is creating a published-episode, not a custom /publish action.
  • Let URLs reveal your domain model. Good resource modeling makes your API self-documenting.