Two-Step View

Posted by Dustin Boston .

The Two-Step View pattern renders domain data into HTML in two distinct stages. The first step transforms the data into a uniform, presentation-oriented structure. This structure is free of specific formatting (like HTML). For example, it might include fields or tables that represent the logical layout of a screen. The second step takes the intermediate structure and renders it to HTML.

Source Code Listing

code.ts

/**
 * Domain Data Model for Artists
 */
const artists = [
  {name: "Eminem", genre: "Rap", origin: "Detroit, MI"},
  {name: "Deftones", genre: "Alternative Metal", origin: "Sacramento, CA"},
  {name: "Sublime", genre: "Ska Punk", origin: "Long Beach, CA"},
];

export class Artist {
  static findNamed(name: string | null): Artist | undefined {
    if (!name) return undefined;

    const artist = artists.find(
      (x) => x.name.toLowerCase() === name.toLowerCase(),
    );

    return artist
      ? new Artist(artist.name, artist.genre, artist.origin)
      : undefined;
  }

  constructor(
    public name: string,
    public genre: string,
    public origin: string,
  ) {}
}

const albums = [
  {artist: "Eminem", title: "The Marshall Mathers LP", year: 2000},
  {artist: "Deftones", title: "White Pony", year: 2000},
  {artist: "Sublime", title: "40oz. to Freedom", year: 1992},
];

export class Album {
  static findNamed(name: string | null): Album[] {
    if (!name) return [];

    return albums
      .filter((album) => album.title.toLowerCase() === name.toLowerCase())
      .map((album) => new Album(album.artist, album.title, album.year));
  }

  constructor(
    public artist: string,
    public title: string,
    public year: number,
  ) {}
}

export class Screen {
  constructor(
    public field: string,
    public value: string,
    public table: [key: string, value: string][],
  ) {}
}

export class CustomRequest {
  public model: Artist | undefined;
  constructor(protected request: Request) {}
  get url(): URL {
    return new URL(this.request.url);
  }
}

/**
 * First Step: Transform Artist and Album into Screens
 */
class ArtistScreen {
  constructor(public artist: Artist) {}

  render(): Screen {
    return {
      field: "Artist",
      value: this.artist.name,
      table: [
        ["Genre", this.artist.genre],
        ["Origin", this.artist.origin],
      ],
    };
  }
}

class AlbumScreen {
  constructor(public album: Album) {}

  render(): Screen {
    return {
      field: "Album",
      value: this.album.title,
      table: [
        ["Artist", this.album.artist],
        ["Year", this.album.year.toString()],
      ],
    };
  }
}

/**
 * Second Step: Transform a Screens into HTML
 */
class PageCreator {
  constructor(public screen: Screen) {}

  process() {
    return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>${this.screen.field}: ${this.screen.value}</title>
</head>
<body>
  <h1>${this.screen.field}: ${this.screen.value}</h1>
  <table>
    <tr><th>Property</th><th>Value</th></tr>
    ${this.screen.table
      .map(([key, value]) => `<tr><td>${key}</td><td>${value}</td></tr>`)
      .join("\n")}
  </table>
</body>
</html>
`;
  }
}

/**
 * Front Controller Pattern
 * Implements the two-step pattern.
 */

class ArtistCommand {
  constructor(public request: Request) {}

  process(): Response {
    const url = new URL(this.request.url);
    const name = url.searchParams.get("name");
    const artist = Artist.findNamed(name);
    if (!artist) {
      return new Response("Artist not found", {status: 404});
    }

    const artistScreen = new ArtistScreen(artist).render();
    const pageCreator = new PageCreator(artistScreen);
    return new Response(pageCreator.process(), {status: 200});
  }
}

class AlbumCommand {
  constructor(public request: Request) {}

  process(): Response {
    const url = new URL(this.request.url);
    const name = url.searchParams.get("name");
    const albums = Album.findNamed(name);
    if (albums.length === 0) {
      return new Response("Album not found", {status: 404});
    }

    const albumScreen = new AlbumScreen(albums[0]).render();
    const pageCreator = new PageCreator(albumScreen);
    return new Response(pageCreator.process(), {status: 200});
  }
}

Bun.serve({
  routes: {
    "/artist": (req) => {
      return new ArtistCommand(req).process();
    },
    "/album": (req) => {
      return new AlbumCommand(req).process();
    },
  },
});