Skip to main content

ViewModels in Serialization

While Dream serializers are powerful for transforming Dream models or plain objects directly, there are scenarios where introducing a ViewModel can significantly clarify your data preparation logic, especially before serialization.

A ViewModel is essentially a class or interface designed specifically to shape data for a particular view or API response. It's not a Dream model (it doesn't directly map to a database table) but rather an intermediate structure that you populate with data, often from one or more Dream models or other sources.

When to Use a ViewModel

Consider using a ViewModel in the following situations:

  1. Composite Output from Multiple Models: When your API response needs to combine data from several different Dream models or data sources into a single, cohesive structure. A ViewModel can act as an aggregator, providing a clean interface for the serializer.

    Example: An API endpoint for a dashboard might need to display a user's profile information, a summary of their recent activity, and some site-wide statistics. A DashboardViewModel could gather and structure this disparate data before it's passed to an ObjectSerializer.

  2. Complex Data Transformations: If the logic to derive certain fields for your API response is complex, involving multiple steps, calculations, or conditional logic, embedding this directly into serializer customAttribute functions can become unwieldy and hard to test. A ViewModel can encapsulate this complex transformation logic within its constructor or methods. The serializer then simply reads pre-computed properties from the ViewModel.

    Example: Generating a complex "status" field for an order that depends on its payment history, shipment status, and return requests. An OrderViewModel could have a method or constructor logic to determine this status, and the serializer would just render viewModel.status.

  3. Needing Asynchronous Operations for Data Preparation: Dream serializers are intentionally synchronous (see Overview for more details). This design choice helps prevent accidental N+1 query problems or other side effects during the serialization phase itself, which should be a straightforward data transformation step. If you need to perform asynchronous operations (e.g., fetching additional data from an external API, performing an async calculation) to prepare data for an API response, this asynchronous work should happen before serialization. A ViewModel is an excellent place to orchestrate these async operations. You can populate the ViewModel instance, performing any necessary async calls in its constructor or dedicated async factory method, and then pass the fully populated, synchronous ViewModel instance to an ObjectSerializer.

    Example: An API response needs to include a user's Gravatar URL. Fetching this might involve an async HTTP request. A UserProfileViewModel could have an async create static method that fetches the user data and the Gravatar URL, then instantiates the ViewModel. This ViewModel is then passed to the serializer.

    // Simplified Example
    // Assume User is your Dream model
    // import User from 'app/models/user';

    class UserProfileViewModel {
    public id: string
    public name: string
    public gravatarUrl?: string

    private constructor(user: /* User */ any, gravatarUrl?: string) {
    this.id = user.id
    this.name = user.name // Assuming 'name' is a property on your User model
    this.gravatarUrl = gravatarUrl
    }

    public static async create(user: /* User */ any): Promise<UserProfileViewModel> {
    let gravatarUrl: string | undefined
    // const fetchGravatar = async (email: string) => Promise.resolve(`https://gravatar.com/avatar/${email}?d=identicon`);
    try {
    // gravatarUrl = await fetchGravatar(user.email); // Async operation
    } catch (error) {
    // console.error("Failed to fetch Gravatar:", error);
    }
    return new UserProfileViewModel(user, gravatarUrl)
    }
    }

    // In your controller:
    // import { ObjectSerializer } from '@rvoh/dream';
    // const user = await User.find(userId);
    // if (user) {
    // const userViewModel = await UserProfileViewModel.create(user);
    // this.ok(ObjectSerializer(userViewModel)
    // .attribute('id', { openapi: 'string' })
    // .attribute('name', { openapi: 'string' })
    // .attribute('gravatarUrl', { openapi: { type: ['string', 'null'] } })
    // .render()
    // );
    // }

Serializing ViewModels

ViewModels are typically serialized using ObjectSerializer because they are plain objects or class instances, not Dream models. You'll need to explicitly define the openapi shape for each attribute when using ObjectSerializer.

By using ViewModels, you can keep your data preparation logic clean, testable, and separate from the direct concerns of serialization, leading to more maintainable and robust applications.