view models
Sometimes you need to serialize something that isn't a Dream model — maybe you're combining data from multiple models, or you need to do some async work to compute a field before serializing. That's where view models come in.
A view model is just a plain class or object that you build up with the data you need, then hand off to ObjectSerializer for rendering. Since Dream serializers are synchronous by design, any async work (like fetching from an external API) needs to happen before serialization — and a view model is a natural place to do that.
when to reach for a view model
You'll want a view model when:
- your response combines data from several models that don't have a direct association
- you need to do async work (external API calls, complex calculations) to compute a field
- the transformation logic is complex enough that cramming it into
customAttributecallbacks would get ugly
example
class DashboardViewModel {
public userName: string
public recentActivityCount: number
public gravatarUrl: string | undefined
private constructor(user: User, activityCount: number, gravatarUrl?: string) {
this.userName = user.name
this.recentActivityCount = activityCount
this.gravatarUrl = gravatarUrl
}
public static async build(user: User) {
const activityCount = await Activity.where({ userId: user.id }).count()
const gravatarUrl = await fetchGravatar(user.email).catch(() => undefined)
return new DashboardViewModel(user, activityCount, gravatarUrl)
}
}
Then in your controller:
public async show() {
const user = await User.findOrFail(this.castParam('id', 'bigint'))
const viewModel = await DashboardViewModel.build(user)
this.ok(
ObjectSerializer(viewModel)
.attribute('userName', { openapi: 'string' })
.attribute('recentActivityCount', { openapi: 'integer' })
.attribute('gravatarUrl', { openapi: ['string', 'null'] })
.render()
)
}
Since ObjectSerializer doesn't have access to any database schema, you'll need to provide the openapi option for every attribute.