Skip to content

Layered architecture using services (MVVM model)

John Arias Castillo requested to merge nuxt-services-poc into contentful

MVVM architecture proposal

Introduction

As our project continues to evolve and expand, so does its inherent complexity. In the realm of medium to large applications, the adoption of a well-defined architectural design pattern becomes not just a recommendation, but a critical imperative. The recent integrations of Contentful and Localization have accentuated the need for such a structured approach. In this proposal, we address the growing complexity of our project by advocating for the adoption of the Model-View-ViewModel (MVVM) design pattern. MVVM will not only enhance reusability and scalability but also pave the way for a more organized and maintainable codebase, aligning perfectly with our new integrations and the broader goals of our development efforts.

The MVVM Pattern

The MVVM pattern is a time-tested architectural design that has proven to be highly effective in separating concerns within modern software applications. It consists of three main components:

Model: Represents the data and business logic of our application. It encapsulates the core functionality and ensures data integrity.

View: Represents the user interface elements and is responsible for displaying data to the user. It is passive and does not contain business logic.

ViewModel: Serves as an intermediary between the Model and the View. It contains the presentation logic, translating data from the Model into a format that the View can easily display. It also captures user interactions and updates the Model accordingly.

Advantages of MVVM:

Improved Separation of Concerns: MVVM enforces a clear separation between the service layer (Model) and the view layer (View and ViewModel), making the codebase more modular and maintainable.

Flexibility and Adaptability: MVVM allows us to make changes to the user interface (View) without affecting the underlying business logic (Model), promoting flexibility and ease of adaptation to evolving requirements.

Implementation

For this Proof of Concept implementation, we will use the solutions template as an illustrative example. We are introducing specific logic that considers the retrieval of localized content from local YML files, while English content is sourced from Contentful. It's important to note that, in this example, we are bypassing the Contentful logic.

ViewModel

The ViewModel serves as an intermediary between the view layer (View) and the service layer (Model), independent of the data source. The ViewModel is represented using Data Transfer Object (DTO) interfaces. These interfaces are responsible for binding data to the user interface (UI).

// models/solutions.dto.ts

import { PageDTO } from '../page.dto';

export interface SolutionsViewDTO extends PageDTO {
  components?: Array<any>;
  template?: string;
  // Include any attributes that might be utilized by the view for rendering...
}

// Add any other DTOs related to solutions in this file...

Model - ViewModel

The service layer operates by injecting service classes and employs the Dependency Injection pattern which is implemented using Plugin Injection Feature by Nuxt. Within this layer, all formatting, serialization, and validation logic is centralized. It is important to emphasize that regardless of the source or data processing requirements, the getContent method within the service layer consistently returns the corresponding DTO interface (ViewModel). The Hybrid approach recommended by @meganfilo should be implemented here.

Service Class

// /services/solutions.service.ts

import { Context } from '@nuxt/types';
import { SolutionsViewDTO } from '../models';

// In this class will be contained all business/data related logic,
// Private functions can be added to do all the logic to read data from any source (Contentful, YML, API, etc...)
// The service should always return the viewDTO that is the one that pages/components are interested in.
export class SolutionsService {
  private readonly $ctx: Context;

  constructor($context: Context) {
    this.$ctx = $context;
  }

  // Logic to format local data
  private formatLocalData(localData: any): SolutionsViewDTO {
    return { ...localData };
  }

  // Logic to format Contentful data(remove fields, sys, includes, etc...)
  private formatContentfulData(contentfulData: any): SolutionsViewDTO {
    return { ...contentfulData, components: [] };
  }

  private async getLocalContent(
    slug: string,
    tempLocale: string,
  ): Promise<SolutionsViewDTO> {
    const localContent = await this.$ctx.$content(tempLocale, slug).fetch();

    return this.formatLocalData(localContent);
  }

  // Here will be located the logic to consume data from Contentful
  private getContentfulContent(slug: string): SolutionsViewDTO {
    // Logic to consume contentful getClient.getEntries(), etc...
    console.log(slug);
    return this.formatContentfulData({});
  }

  public async getContent(slug: string): Promise<SolutionsViewDTO> {
    const isDefaultLocale =
      this.$ctx.i18n.locale === this.$ctx.i18n.defaultLocale;

    if (isDefaultLocale) {
      // Return Contentful data (In short future)
      return this.getLocalContent(slug, '');
    }

    // Return localization data
    return Promise.resolve(this.getLocalContent(slug, this.$ctx.i18n.locale));
  }
}

Service Injection

// plugins/services.ts

import { Context } from '@nuxt/types';
import { Inject } from '@nuxt/types/app';
import { SolutionsService } from '../services';

// eslint-disable-next-line import/no-default-export
export default (ctx: Context, inject: Inject) => {
  const solutionsService = new SolutionsService(ctx); // Singleton service

  inject('solutionsService', solutionsService);
  // Inject more services here...
};
// nuxt.config.js

// ...
  plugins: [
    '~/plugins/slippers-ui.ts',
    '~/plugins/be-navigation.ts',
    '~/plugins/services.ts',
    { src: '~/plugins/oneTrust.js', mode: 'client' },
    { src: '~/plugins/gtm.js', mode: 'client' },
    { src: '~/plugins/swiftype.js', mode: 'client' },
// ...

View

The view layer has a straightforward responsibility: to call the injected service. This service, in turn, retrieves the view-model DTO. The sole focus of the view layer is to render the data for presentation, ensuring a clear separation of concerns. Some bindings should be updated to comply with the created DTO's.

// pages/solutions/_solutions.vue

// ...
async asyncData({ params, error, $solutionsService }: any) {
    try {
      const solutions: SolutionsViewDTO = await $solutionsService.getContent(
        `solutions/${params.solutions ? params.solutions : 'index'}`,
      );

      return { solutions };
    } catch (e) {
      error({ statusCode: 404 });
      return {};
    }
  },
// ...

Diagram

image

Conclusion

By adopting the MVVM pattern and separating the service layer from the view layer, we can enhance the maintainability, scalability, and testability of our project. This architectural improvement will not only benefit our current development efforts but also lay a solid foundation for future enhancements and collaborations within our team. We look forward to your feedback and collaboration as we embark on this transformative journey for our project.

Review APP

Edited by John Arias Castillo

Merge request reports