はじめに
業務アプリケーションの開発では、通常、データベース層、バックエンドロジック層、フロントエンドプレゼンテーション層など、複数の層を構築する必要があります。スキーマの変更が必要な場合、サーバサイドドメインのエンティティやクライアントのインターフェースなど、関連するコードを修正する必要があります。このような変更に対処する際には、DRY原則1を実現するためにスキーマ情報を一元管理し、各層のエンティティや構造体、型情報を単一のスキーマ情報から生成できるようにしておくことが重要です。これにより、変更に強いアプリケーション構造を確立することが可能となります。
今回は Angular の Schematics を使って、フロントエンドのプログラムを生成するジェネレータを作成してみようと思います。
Angular Schematics とは
Angular Schematicsは、Angularアプリケーションやライブラリのためのコード生成ツールです。Angular CLI に統合されており、ng generate や ng add でコードを生成する際に使われています。Schematicsは、コードの骨組みや構造を自動的に生成し、プロジェクトの設定やファイル構造を効果的に構築するために使用されます。自作のライブラリを作成してnpmパッケージとして公開したものを配布する時にも、Schematics を用意しておくことで CLI と統合できるようになっています。@angular/material や @ng-bootstrap/ng-bootstrap をCLIコマンドを使ってプロジェクトに追加できるのも Schematics によるものです。
検証
検証用のプロジェクトを作成して、動作を確かめていこうと思います。下記コマンドで Schematics のプロジェクトを作成します。
npm install -g @angular-devkit/schematics-cli
mkdir labo-angular-schematics
cd labo-angular-schematics
schematics blank --name=hello-world
cd hello-world
schematics .:hello-world
生成されるファイルの役割や、Schematicsライブラリの各関数を使ったカスタマイズ方法は下記の記事を参考にしてください。
記事の通りテンプレートファイルからAngularコンポーネントを生成できる前提で検証を続けていきます。
ジェネレータの実装
Schematicの出力をカスタマイズします。メインの処理となる index.ts は前述の記事を参考に作成しています。
import { strings } from '@angular-devkit/core';
import { Rule, SchematicContext, apply, applyTemplates, mergeWith, move, url } from '@angular-devkit/schematics';
import { normalize } from 'path';
const fs = require('fs');
const path = require('path');
function deleteFilesInDirectory(directoryPath: string) {
  if (fs.existsSync(directoryPath)) {
    fs.readdirSync(directoryPath).forEach((file: File) => {
      const filePath = path.join(directoryPath, file);
      if (fs.lstatSync(filePath).isDirectory()) {
        // ディレクトリの場合、再帰的に削除
        deleteFilesInDirectory(filePath);
      } else {
        // ファイルの場合、削除
        fs.unlinkSync(filePath);
        console.log(`Deleted file: ${filePath}`);
      }
    });
    // ディレクトリを削除
    fs.rmdirSync(directoryPath);
    console.log(`Deleted directory: ${directoryPath}`);
  }
}
// You don't have to export the function as default. You can also have more than one rule factory
// per file.
export function helloWorld(args: any): Rule {
  const options = JSON.parse(args.options)
  deleteFilesInDirectory(options.distPath)
  return (_, _context: SchematicContext) => {
    return mergeWith(apply(url(options.templatePath), [
      applyTemplates({
        ...strings,
        ...options,
        name: options.name,
      }),
      move(normalize(options.distPath as string))
    ]));
  };}
SchematicsはCLIコマンドで schematics .:hello-world --dry-run=false --options='{"key": "value"}' といったように任意のオプションを指定することができます。コードを自動生成する際のパラメータ情報をこの引数から、この後作成するテンプレートファイルに渡すため、applyTemplates() に ...options と、オブジェクト全てを指定します。またプログラムの生成処理前に、前回生成したディレクトリとファイルを削除するようにしたいため、deleteFilesInDirectory()という関数を作成し、実行するようにしています。
スキーマ情報の作成
スキーマの情報を外部ファイルにJSON形式で以下の通りに用意します。今回は顧客情報のマスタテーブルのデータを編集する画面を作成します。Angularのプロジェクトで TypeScript と HTML の型情報と属性情報を決めておきます。
{
	"name": "customer-master",
	"name_ja_JP": "顧客マスタ",
	"templatePath": "./customer-template",
	"distPath": "./dist/master/customer",
	"fields": [
		{
			"key": "customerCode",
			"name_ja_JP": "顧客コード",
			"ts": { "type": ["number"], "optional": false },
			"ui": { "type": "number", "required": true }
		},
		{
			"key": "customerName",
			"name_ja_JP": "お客様名",
			"ts": { "type": ["number"], "optional": false },
			"ui": { "type": "number", "required": true }
		},
		{
			"key": "address",
			"name_ja_JP": "住所",
			"ts": { "type": ["number"], "optional": false },
			"ui": { "type": "number", "required": true }
		},
		{
			"key": "tel",
			"name_ja_JP": "電話番号",
			"ts": { "type": ["number"], "optional": false },
			"ui": { "type": "number", "required": true }
		}
	]
}
実行スクリプトの作成
Schematicsのコマンドと、JSONファイルからオプションを指定する部分のスクリプトを作成しておきます。JavaScriptで実装して、Node.jsを使って実行します。
const fs = require('fs');
const { execSync } = require('child_process');
const defineFilePath = './src/hello-world/customer.json'
const optionData = fs.readFileSync(defineFilePath, 'utf-8')
const options = JSON.parse(optionData)
execSync(`
	schematics .:hello-world --dry-run=false --options='${JSON.stringify(options)}'
	`, { stdio: 'inherit' });
TypeScriptのインターフェースの生成
それではここから、実際のアプリケーションのプログラムを生成していきましょう。まずはTypeScriptのインターフェースを作成します。
テンプレートファイルの作成
作成したスキーマ情報からコードを生成するための鋳型を作ります。テンプレートの記述方法で fields の配列から型定義を作成します。また、RDBの業務データを管理するシステムで、登録や更新が実施されるときの時間やユーザを記録しておくことが多く、生成されるオブジェクトにはデフォルトで entry_time や update_user の項目が作成されるようにします。
export interface <%= classify(name) %> {
	id: string;
	<% fields.forEach(function(r) { %>
	<%= r.key %><%= r.ts.optional ? '?' : '' %>: <%= r.ts.type.join(' | ') %>;
	<% }); %>
	entryUser: string;
	updateUser: string;
	updateCount: number;
}
ファイル名に __name__ とすることで、index.ts の applyTemplates()  に指定した name を利用することができます。またファイル名のアットマークの後ろにユーティリティを宣言することで、関数の命名記法を変換することができます。このユーティリティが@angular-devkit/coreで実装されているようですが、Angularの公式ドキュメントには説明が見当たらず、いろいろ試しながら使っています。GitHubで見ることができる実装を参考にしています。
コード生成
プログラムの生成を試してみます。先のジェネレータを実行して、スキーマ情報をテンプレートに流し込み、出力されるコードを確認します。
% node _schematics.js
Debug mode enabled by default for local collections.
CREATE dist/master/customer/CustomerMaster.ts (202 bytes)
下記のプログラムができました。
export interface CustomerMaster {
	id: string;
	customerCode: number;	
	customerName: string;
	address?: string;
	tel?: string;	
	entryUser: string;
	updateUser: string;
	updateCount: number;
}
Angularサービスとコンポーネントの生成
サーバサイドのAPIへリクエストするクライアントサイドのサービスと、画面を動作させるコンポーネントを生成します。コード生成までの基本的な流れは、TyypeScriptのインターフェースを生成した時と同じです。スキーマ情報をインプットとし、意図したプログラムをアウトプットするテンプレートを作っていくこととなります。
アプリケーションは、一覧画面でDBのレコードを複数件表示し、1件クリックすると詳細画面に遷移し、データ項目を編集して保存するといった汎用的な機能としています。一覧表示にはグリッドのライブラリを使用しています。固有の実装もありますが、開発しているプロジェクトに組み込むことができるということの参考としてください。
サービスのテンプレート
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import ENV_CONST from 'env/env.json';
import { <%= classify(name) %> } from './<%= classify(name) %>'
@Injectable({providedIn: 'root'})
export class <%= classify(name) %>Service {
	constructor(
		private http: HttpClient,
	){}
	fetch<%= classify(name) %>() {
		const url = ENV_CONST.PM_API_ENDPOINT + '/api/master/<%= decamelize(name) %>/list'
		return this.http.get(url)
	}
	regist<%= classify(name) %>Async(body: <%= classify(name) %>){
		const url = ENV_CONST.PM_API_ENDPOINT + '/api/master/<%= decamelize(name) %>'
		return this.http.post(url, body)
	}
	update<%= classify(name) %>Async(id: string, body: <%= classify(name) %>) {
		const url = ENV_CONST.PM_API_ENDPOINT + `/api/master/<%= decamelize(name) %>/${id}`
		return this.http.patch(url, body)
	}
	remove<%= classify(name) %>Async(id: string) {
		const url = ENV_CONST.PM_API_ENDPOINT + `/api/master/<%= decamelize(name) %>/${id}`
		return this.http.delete(url)
	}
}
一覧画面コンポーネントのテンプレート
import { Component, ElementRef } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { Ng2StateDeclaration, UIRouterGlobals, StateService } from '@uirouter/angular';
import { TranslateService } from '@ngx-translate/core';
import ENV_CONST from 'env/env.json';
import { SharedModule, IgGridHelperService, DialogService, ToastService, UtilitiesService } from '../../common/modules/shared.module';
import { <%= classify(name) %>Service } from './<%= decamelize(name)%>.service'
import { <%= classify(name) %> } from './<%= classify(name) %>'
@Component({
	templateUrl: './<%= decamelize(name) %>-list.component.html',
	standalone: true,
	imports: [SharedModule],
})
export class Master<%= classify(name) %>ListComponent {
	readonly CONST = ENV_CONST;
	$element: JQuery<HTMLElement>;
	$grid: JQuery<IgGrid>;
	isMobile: boolean;
	search: {
		autoRefreshEnabled: boolean,
		autoRefreshInterval: boolean,
		filteringText: string,
	}
	gridId: string = '<%= decamelize(name) %>-grid';
	gridOptions: IgGrid;
	defaultColumns: IgGridColumn[];
	dataSource: <%= classify(name) %>[];
	constructor(
		private elementRef: ElementRef,
		private uiRouterGlobals: UIRouterGlobals,
		private state: StateService,
		private translate: TranslateService,
		private dialog: DialogService,
		private toast: ToastService,
		private utilities: UtilitiesService,
		private IgGridHelperService: IgGridHelperService,
		private <%= camelize(name) %>Service: <%= classify(name) %>Service,
	) {
		this.$element = $(this.elementRef.nativeElement);
		this.search = { ...this.uiRouterGlobals.params } as any;
		this._loadDataSource();
	}
	private _loadDataSource() {
		this.<%= camelize(name) %>Service.fetch<%= classify(name) %>()
			.subscribe((res: any) => {
				this.dataSource = res.items;
				setTimeout(() => this._createGrid())
			})
	}
	private _createGrid() {
		this.defaultColumns = this.IgGridHelperService.createAppearanceColumns([
			{ key: 'control', headerText: 'control', template: this.$element.find('#ItemListControlTmpl').html() },
			{ key: 'id', headerText: 'id', dataType: 'string', hidden: true },
			<% fields.forEach(function(r) { %>
			{ key: '<%= r.key %>', headerText: '<%= r.key %>', dataType: '<%= r.ts.type.join(' | ') %>' },
			<%   }); %>
			{ key: 'entryUser', headerText: 'entryUser', dataType: 'string' },
			{ key: 'updateUser', headerText: 'updateUser', dataType: 'string' },
			{ key: 'updateCount', headerText: 'updateCount', dataType: 'string' },
		], this.gridId)
		this.gridOptions = {
			autoGenerateColumns: false,
			columns: this.defaultColumns,
			dataSource: this.dataSource,
			features: [
				{ name: 'Sorting' },
				{ name: 'ColumnMoving' },
				{ name: 'Filtering', mode: 'advanced' },
				{ name: 'Selection' },
				{ name: 'Hiding' },
				{ name: 'Paging', type: 'local', pageSize: 20 },
			],
			rendered: () => this._rendered(),
			dataRendered: (__: any, ui: any) => this._dataRendered(ui)
		}
		this.$grid = $('#' + this.gridId).igGrid(this.gridOptions)
	}
	private _rendered() {
		$(".ui-igloadingmsg").removeClass("ui-igloadingmsg")
	}
	private _dataRendered(ui: any) {
		if (!($.fn.igGrid as any).appearance['<%= decamelize(name) %>-grid']) {
			this.IgGridHelperService.autoSizeColumns('<%= decamelize(name) %>-grid')
		}
		this.$element.find('button[name=edit]').off('click').on('click', (e) => this._editRow(e))
		this.$element.find('button[name=copy]').off('click').on('click', (e) => this._copyRow(e))
		this.$element.find('button[name=delete]').off('click').on('click', (e) => this._deleteRow(e))
	}
	addRow() {
		this.state.go('<%= decamelize(name) %>-register', { 'mode': 'register' })
	}
	private _editRow(evt: any) {
		const id = evt.currentTarget.getAttribute('<%= decamelize(name) %>-id')
		var data = this.dataSource.find((row) => row.id === id)
		this.state.go('<%= decamelize(name) %>-register', { 'mode': 'modify', 'data': data })
	}
	private _copyRow(evt: any) {
		const id = evt.currentTarget.getAttribute('<%= decamelize(name) %>-id')
		var data = this.dataSource.find((row) => row.id === id)
		this.state.go('<%= decamelize(name) %>-register', { 'mode': 'register', 'data': data })
	}
	private _deleteRow(evt: any) {
		const id = evt.currentTarget.getAttribute('<%= decamelize(name) %>-id')
		this.dialog.deleteConfirm(id).closed.subscribe(() => {
			this.<%= camelize(name) %>Service.remove<%= classify(name) %>Async(id).subscribe({
				next: res => {
					if (!this.toast.showres(res)) this.toast.dataDeleteSuccess()
					this._loadDataSource()
				},
				error: (err: HttpErrorResponse) => {
					const res = err.error || {}
					if (!this.toast.showres(res)) this.toast.dataDeleteError()
				}
			})
		})
	}
}
const stateName = '<%= decamelize(name) %>-list';
export const master<%= classify(name) %>State: Ng2StateDeclaration = {
	name: stateName,
	url: '/<%= decamelize(name) %>-list',
	params: {},
	component: Master<%= classify(name) %>ListComponent,
	resolve: []
}
<page-title>
</page-title>
<div class="wrapper wrapper-content">
  <div class="ibox">
    <div class="ibox-title">
      <i class="fa fa-play-circle-o"></i>
      <span [translate]="'master.<%= decamelize(name) %>.title'"><%= decamelize(name) %></span>
      <ibox-config-tool [gridId]="gridId"></ibox-config-tool>
    </div>
    <div class="ibox-content">
      <div class="clearfix">
        <div class="pull-left m-b-sm">
          <button type="submit" scButtonStyle="main" [translate]="'common.button.add_new'" (click)="addRow()">add</button>
        </div>
        <div class="pull-right m-b-sm">
        </div>
      </div>
      <ng-container>
        <div style="position: relative;">
          <incremental-search-tool [gridId]="gridId" [(inputText)]="search.filteringText"></incremental-search-tool>
        </div>
        <table [id]="gridId" gridStatePersistence></table>
      </ng-container>
    </div>
  </div>
  <div id="ItemListControlTmpl" [hidden]="true">
    <button name="edit" type="button" class="btn btn-dark btn-outline btn-grid" <%= decamelize(name) %>-id="${id}"
      [translate]="'common.button.edit'">
      edit
    </button>
    <button name="copy" type="button" class="btn btn-dark btn-outline btn-grid" <%= decamelize(name) %>-id="${id}"
      [translate]="'common.button.copy'">
      copy
    </button>
    <button name="delete" type="button" class="btn btn-dark btn-outline btn-grid" <%= decamelize(name) %>-id="${id}"
      [translate]="'common.button.delete'">
      delete
    </button>
  </div>
</div>
詳細画面コンポーネントのテンプレート
import { Component, OnInit } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { Ng2StateDeclaration, UIRouterGlobals } from '@uirouter/angular';
import { TranslateService } from '@ngx-translate/core';
import ENV_CONST from 'env/env.json';
import { SharedModule, PageService, DialogService, ToastService, UtilitiesService } from '../../common/modules/shared.module';
import { <%= classify(name) %>Service } from './<%= decamelize(name)%>.service'
import { <%= classify(name) %> } from './<%= classify(name) %>'
@Component({
	templateUrl: './master-<%= decamelize(name) %>-register.component.html',
	standalone: true,
	imports: [SharedModule],
})
export class Master<%= classify(name) %>RegistorComponent {
		readonly CONST = ENV_CONST
		$stateParams: {
			data: any,
			mode: 'register' | 'modify'
		}
		<%= camelize(name) %>Id: string
		data: <%= classify(name) %>
		mode: 'register' | 'modify'
		photoButtonTitle: string
		constructor(
			private uiRuterGlobals: UIRouterGlobals,
			private toast: ToastService,
			private translate: TranslateService,
			private dialog: DialogService,
			private page: PageService,
			private utilities: UtilitiesService,
			private <%= camelize(name) %>Service: <%= classify(name) %>Service,
		) {
			this.$stateParams = { ... this.uiRuterGlobals.params } as any;
			this.<%= camelize(name) %>Id = this.$stateParams.data?.id
			this.mode = this.$stateParams.mode || 'register'
			this.data = !this.$stateParams.data ? {} as <%= classify(name) %> : { ...this.$stateParams.data } as <%= classify(name) %>
		}
		save() {
			this.dialog.saveConfirm().closed.subscribe(() => {
				let obs$ = this.mode === 'register'
					? this.<%= camelize(name) %>Service.regist<%= classify(name) %>Async(this.utilities.emptyToNull(this.data))
					: this.<%= camelize(name) %>Service.update<%= classify(name) %>Async(this.<%= camelize(name) %>Id, this.utilities.emptyToNull(this.data))
				obs$.subscribe({
					next: (res) => {
						if (!this.toast.showres(res)) this.toast.dataSaveSuccess()
						this.page.backOrGo('master-<%= decamelize(name) %>-list')
					},
					error: (err: HttpErrorResponse) => {
						if (!this.toast.showres(err.error)) this.toast.dataSaveError()
					}
				})
			})
		}
		cancel() {
			this.dialog.editCancelConfirm().closed.subscribe(() => this.page.backOrGo('master-<%= decamelize(name) %>-list'))
		}
}
const stateName = 'master-<%= decamelize(name) %>-register'
export const master<%= classify(name) %>RegistorState: Ng2StateDeclaration = {
	name: stateName,
	url: '/master-<%= decamelize(name) %>-register',
	component: Master<%= classify(name) %>RegistorComponent,
	params: { mode: 'register', data: null },
}
<page-title></page-title>
<div class="wrapper wrapper-content">
	<div class="ibox">
		<div class="ibox-title">
			<i class="fa fa-play-circle-o"></i>
			<span translate="master.<%= decamelize(name) %>.{{mode === 'register' ? 'register': 'edit'}}Title">title</span>
		</div>
		<div class="ibox-content">
			<form method="get" class="form-horizontal" #<%= camelize(name) %>Form="ngForm" (ngSubmit)="<%= camelize(name) %>Form.valid && save()">
				<div class="form-group">
					<label class="col-sm-2 control-label" [translate]="'master.id'">ID</label>
					<div class="col-sm-8">
						<input type="text" class="form-control" name="id" [(ngModel)]="data.id" disabled="true">
					</div>
				</div>
			<% fields.forEach(function(r) { %>
				<div class="form-group">
					<label class="col-sm-2 control-label" [translate]="'master.<%= decamelize(name) %>.<%= r.key %>'">item</label>
					<div class="col-sm-8">
						<input type="<%= r.ui.type %>" class="form-control" name="<%= r.key %>" [(ngModel)]="data.<%= r.key %>" #<%= r.key %>="ngModel" <%= r.ui.required ? 'required' : '' %>>
						<%= r.ui.required ? '<p class="help-block text-danger" *ngIf="' + camelize(name) + 'Form.submitted && ' + r.key + '.invalid" [translate]="\'common.validate.required\'">required</p>' : '' %>
					</div>
				</div>
			<%   }); %>
				<div class="form-group">
					<label class="col-sm-2 control-label" [translate]="'master.entryUser'">entryUser</label>
					<div class="col-sm-8">
						<input type="text" class="form-control" name="entryUser" [(ngModel)]="data.entryUser" disabled="true">
					</div>
				</div>
				<div class="form-group">
					<label class="col-sm-2 control-label" [translate]="'master.updateUser'">updateUser</label>
					<div class="col-sm-8">
						<input type="text" class="form-control" name="updateUser" [(ngModel)]="data.updateUser" disabled="true">
					</div>
				</div>
				<div class="form-group">
					<label class="col-sm-2 control-label" [translate]="'master.updateCount'">updateCount</label>
					<div class="col-sm-8">
						<input type="text" class="form-control" name="updateCount" [(ngModel)]="data.updateCount" disabled="true">
					</div>
				</div>
				<div class="form-group">
					<div class="col-sm-4 col-sm-offset-2">
						<button type="submit" class="btn btn-dark" [translate]="'common.button.save'">save</button>
						<button type="button" class="btn btn-white" (click)="cancel()" [translate]="'common.button.cancel'">cancel</button>
					</div>
				</div>
			</form>
		</div>
	</div>
</div>
それでは、フロントエンドのプログラムを一気に生成します。
$ node _schematics.js 
Debug mode enabled by default for local collections.
CREATE dist/master/customer/CustomerMaster.ts (202 bytes)
CREATE dist/master/customer/customer-master-list.component.html (1503 bytes)
CREATE dist/master/customer/customer-master-list.component.ts (4922 bytes)
CREATE dist/master/customer/customer-master-register.component.html (3439 bytes)
CREATE dist/master/customer/customer-master-register.component.ts (2536 bytes)
CREATE dist/master/customer/customer-master.service.ts (934 bytes)
サービスのプログラム
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import ENV_CONST from 'env/env.json';
import { CustomerMaster } from './CustomerMaster'
@Injectable({providedIn: 'root'})
export class CustomerMasterService {
	constructor(
		private http: HttpClient,
	){}
	fetchCustomerMaster() {
		const url = ENV_CONST.PM_API_ENDPOINT + '/api/master/customer-master/list'
		return this.http.get(url)
	}
	registCustomerMasterAsync(body: CustomerMaster){
		const url = ENV_CONST.PM_API_ENDPOINT + '/api/master/customer-master'
		return this.http.post(url, body)
	}
	updateCustomerMasterAsync(id: string, body: CustomerMaster) {
		const url = ENV_CONST.PM_API_ENDPOINT + `/api/master/customer-master/${id}`
		return this.http.patch(url, body)
	}
	removeCustomerMasterAsync(id: string) {
		const url = ENV_CONST.PM_API_ENDPOINT + `/api/master/customer-master/${id}`
		return this.http.delete(url)
	}
}
一覧画面のプログラム
import { Component, ElementRef } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { Ng2StateDeclaration, UIRouterGlobals, StateService } from '@uirouter/angular';
import { TranslateService } from '@ngx-translate/core';
import ENV_CONST from 'env/env.json';
import { SharedModule, IgGridHelperService, DialogService, ToastService, UtilitiesService } from '../../common/modules/shared.module';
import { CustomerMasterService } from './customer-master.service'
import { CustomerMaster } from './CustomerMaster'
@Component({
	templateUrl: './customer-master-list.component.html',
	standalone: true,
	imports: [SharedModule],
})
export class MasterCustomerMasterListComponent {
	readonly CONST = ENV_CONST;
	$element: JQuery<HTMLElement>;
	$grid: JQuery<IgGrid>;
	isMobile: boolean;
	search: {
		autoRefreshEnabled: boolean,
		autoRefreshInterval: boolean,
		filteringText: string,
	}
	gridId: string = 'customer-master-grid';
	gridOptions: IgGrid;
	defaultColumns: IgGridColumn[];
	dataSource: CustomerMaster[];
	constructor(
		private elementRef: ElementRef,
		private uiRouterGlobals: UIRouterGlobals,
		private state: StateService,
		private translate: TranslateService,
		private dialog: DialogService,
		private toast: ToastService,
		private utilities: UtilitiesService,
		private IgGridHelperService: IgGridHelperService,
		private customerMasterService: CustomerMasterService,
	) {
		this.$element = $(this.elementRef.nativeElement);
		this.search = { ...this.uiRouterGlobals.params } as any;
		this._loadDataSource();
	}
	private _loadDataSource() {
		this.customerMasterService.fetchCustomerMaster()
			.subscribe((res: any) => {
				this.dataSource = res.items;
				setTimeout(() => this._createGrid())
			})
	}
	private _createGrid() {
		this.defaultColumns = this.IgGridHelperService.createAppearanceColumns([
			{ key: 'control', headerText: 'control', template: this.$element.find('#ItemListControlTmpl').html() },
			{ key: 'id', headerText: 'id', dataType: 'string', hidden: true },
			{ key: 'customerCode', headerText: 'customerCode', dataType: 'number' },
			{ key: 'customerName', headerText: 'customerName', dataType: 'string' },
			{ key: 'address', headerText: 'address', dataType: 'string' },
			{ key: 'tel', headerText: 'tel', dataType: 'string' },
			{ key: 'entryUser', headerText: 'entryUser', dataType: 'string' },
			{ key: 'updateUser', headerText: 'updateUser', dataType: 'string' },
			{ key: 'updateCount', headerText: 'updateCount', dataType: 'string' },
		], this.gridId)
		this.gridOptions = {
			autoGenerateColumns: false,
			columns: this.defaultColumns,
			dataSource: this.dataSource,
			features: [
				{ name: 'Sorting' },
				{ name: 'ColumnMoving' },
				{ name: 'Filtering', mode: 'advanced' },
				{ name: 'Selection' },
				{ name: 'Hiding' },
				{ name: 'Paging', type: 'local', pageSize: 20 },
			],
			rendered: () => this._rendered(),
			dataRendered: (__: any, ui: any) => this._dataRendered(ui)
		}
		this.$grid = $('#' + this.gridId).igGrid(this.gridOptions)
	}
	private _rendered() {
		$(".ui-igloadingmsg").removeClass("ui-igloadingmsg")
	}
	private _dataRendered(ui: any) {
		if (!($.fn.igGrid as any).appearance['customer-master-grid']) {
			this.IgGridHelperService.autoSizeColumns('customer-master-grid')
		}
		this.$element.find('button[name=edit]').off('click').on('click', (e) => this._editRow(e))
		this.$element.find('button[name=copy]').off('click').on('click', (e) => this._copyRow(e))
		this.$element.find('button[name=delete]').off('click').on('click', (e) => this._deleteRow(e))
	}
	addRow() {
		this.state.go('customer-master-register', { 'mode': 'register' })
	}
	private _editRow(evt: any) {
		const id = evt.currentTarget.getAttribute('customer-master-id')
		var data = this.dataSource.find((row) => row.id === id)
		this.state.go('customer-master-register', { 'mode': 'modify', 'data': data })
	}
	private _copyRow(evt: any) {
		const id = evt.currentTarget.getAttribute('customer-master-id')
		var data = this.dataSource.find((row) => row.id === id)
		this.state.go('customer-master-register', { 'mode': 'register', 'data': data })
	}
	private _deleteRow(evt: any) {
		const id = evt.currentTarget.getAttribute('customer-master-id')
		this.dialog.deleteConfirm(id).closed.subscribe(() => {
			this.customerMasterService.removeCustomerMasterAsync(id).subscribe({
				next: res => {
					if (!this.toast.showres(res)) this.toast.dataDeleteSuccess()
					this._loadDataSource()
				},
				error: (err: HttpErrorResponse) => {
					const res = err.error || {}
					if (!this.toast.showres(res)) this.toast.dataDeleteError()
				}
			})
		})
	}
}
const stateName = 'customer-master-list';
export const masterCustomerMasterState: Ng2StateDeclaration = {
	name: stateName,
	url: '/customer-master-list',
	params: {},
	component: MasterCustomerMasterListComponent,
	resolve: []
}
<page-title>
</page-title>
<div class="wrapper wrapper-content">
  <div class="ibox">
    <div class="ibox-title">
      <i class="fa fa-play-circle-o"></i>
      <span [translate]="'master.customer-master.title'">customer-master</span>
      <ibox-config-tool [gridId]="gridId"></ibox-config-tool>
    </div>
    <div class="ibox-content">
      <div class="clearfix">
        <div class="pull-left m-b-sm">
          <button type="submit" scButtonStyle="main" [translate]="'common.button.add_new'" (click)="addRow()">add</button>
        </div>
        <div class="pull-right m-b-sm">
        </div>
      </div>
      <ng-container>
        <div style="position: relative;">
          <incremental-search-tool [gridId]="gridId" [(inputText)]="search.filteringText"></incremental-search-tool>
        </div>
        <table [id]="gridId" gridStatePersistence></table>
      </ng-container>
    </div>
  </div>
  <div id="ItemListControlTmpl" [hidden]="true">
    <button name="edit" type="button" class="btn btn-dark btn-outline btn-grid" customer-master-id="${id}"
      [translate]="'common.button.edit'">
      edit
    </button>
    <button name="copy" type="button" class="btn btn-dark btn-outline btn-grid" customer-master-id="${id}"
      [translate]="'common.button.copy'">
      copy
    </button>
    <button name="delete" type="button" class="btn btn-dark btn-outline btn-grid" customer-master-id="${id}"
      [translate]="'common.button.delete'">
      delete
    </button>
  </div>
</div>
詳細画面のプログラム
import { Component, OnInit } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { Ng2StateDeclaration, UIRouterGlobals } from '@uirouter/angular';
import { TranslateService } from '@ngx-translate/core';
import ENV_CONST from 'env/env.json';
import { SharedModule, PageService, DialogService, ToastService, UtilitiesService } from '../../common/modules/shared.module';
import { CustomerMasterService } from './customer-master.service'
import { CustomerMaster } from './CustomerMaster'
@Component({
	templateUrl: './master-customer-master-register.component.html',
	standalone: true,
	imports: [SharedModule],
})
export class MasterCustomerMasterRegistorComponent {
		readonly CONST = ENV_CONST
		$stateParams: {
			data: any,
			mode: 'register' | 'modify'
		}
		customerMasterId: string
		data: CustomerMaster
		mode: 'register' | 'modify'
		photoButtonTitle: string
		constructor(
			private uiRuterGlobals: UIRouterGlobals,
			private toast: ToastService,
			private translate: TranslateService,
			private dialog: DialogService,
			private page: PageService,
			private utilities: UtilitiesService,
			private customerMasterService: CustomerMasterService,
		) {
			this.$stateParams = { ... this.uiRuterGlobals.params } as any;
			this.customerMasterId = this.$stateParams.data?.id
			this.mode = this.$stateParams.mode || 'register'
			this.data = !this.$stateParams.data ? {} as CustomerMaster : { ...this.$stateParams.data } as CustomerMaster
		}
		save() {
			this.dialog.saveConfirm().closed.subscribe(() => {
				let obs$ = this.mode === 'register'
					? this.customerMasterService.registCustomerMasterAsync(this.utilities.emptyToNull(this.data))
					: this.customerMasterService.updateCustomerMasterAsync(this.customerMasterId, this.utilities.emptyToNull(this.data))
				obs$.subscribe({
					next: (res) => {
						if (!this.toast.showres(res)) this.toast.dataSaveSuccess()
						this.page.backOrGo('master-customer-master-list')
					},
					error: (err: HttpErrorResponse) => {
						if (!this.toast.showres(err.error)) this.toast.dataSaveError()
					}
				})
			})
		}
		cancel() {
			this.dialog.editCancelConfirm().closed.subscribe(() => this.page.backOrGo('master-customer-master-list'))
		}
}
const stateName = 'master-customer-master-register'
export const masterCustomerMasterRegistorState: Ng2StateDeclaration = {
	name: stateName,
	url: '/master-customer-master-register',
	component: MasterCustomerMasterRegistorComponent,
	params: { mode: 'register', data: null },
}
<page-title></page-title>
<div class="wrapper wrapper-content">
	<div class="ibox">
		<div class="ibox-title">
			<i class="fa fa-play-circle-o"></i>
			<span translate="master.customer-master.{{mode === 'register' ? 'register': 'edit'}}Title">title</span>
		</div>
		<div class="ibox-content">
			<form method="get" class="form-horizontal" #customerMasterForm="ngForm" (ngSubmit)="customerMasterForm.valid && save()">
				<div class="form-group">
					<label class="col-sm-2 control-label" [translate]="'master.id'">ID</label>
					<div class="col-sm-8">
						<input type="text" class="form-control" name="id" [(ngModel)]="data.id" disabled="true">
					</div>
				</div>			
				<div class="form-group">
					<label class="col-sm-2 control-label" [translate]="'master.customer-master.customerCode'">item</label>
					<div class="col-sm-8">
						<input type="number" class="form-control" name="customerCode" [(ngModel)]="data.customerCode" #customerCode="ngModel" required>
						<p class="help-block text-danger" *ngIf="customerMasterForm.submitted && customerCode.invalid" [translate]="'common.validate.required'">required</p>
					</div>
				</div>			
				<div class="form-group">
					<label class="col-sm-2 control-label" [translate]="'master.customer-master.customerName'">item</label>
					<div class="col-sm-8">
						<input type="text" class="form-control" name="customerName" [(ngModel)]="data.customerName" #customerName="ngModel" required>
						<p class="help-block text-danger" *ngIf="customerMasterForm.submitted && customerName.invalid" [translate]="'common.validate.required'">required</p>
					</div>
				</div>
				<div class="form-group">
					<label class="col-sm-2 control-label" [translate]="'master.customer-master.address'">item</label>
					<div class="col-sm-8">
						<input type="text" class="form-control" name="address" [(ngModel)]="data.address" #address="ngModel" >
						
					</div>
				</div>
				<div class="form-group">
					<label class="col-sm-2 control-label" [translate]="'master.customer-master.tel'">item</label>
					<div class="col-sm-8">
						<input type="text" class="form-control" name="tel" [(ngModel)]="data.tel" #tel="ngModel" >
						
					</div>
				</div>
				<div class="form-group">
					<label class="col-sm-2 control-label" [translate]="'master.entryUser'">entryUser</label>
					<div class="col-sm-8">
						<input type="text" class="form-control" name="entryUser" [(ngModel)]="data.entryUser" disabled="true">
					</div>
				</div>
				<div class="form-group">
					<label class="col-sm-2 control-label" [translate]="'master.updateUser'">updateUser</label>
					<div class="col-sm-8">
						<input type="text" class="form-control" name="updateUser" [(ngModel)]="data.updateUser" disabled="true">
					</div>
				</div>
				<div class="form-group">
					<label class="col-sm-2 control-label" [translate]="'master.updateCount'">updateCount</label>
					<div class="col-sm-8">
						<input type="text" class="form-control" name="updateCount" [(ngModel)]="data.updateCount" disabled="true">
					</div>
				</div>
				<div class="form-group">
					<div class="col-sm-4 col-sm-offset-2">
						<button type="submit" class="btn btn-dark" [translate]="'common.button.save'">save</button>
						<button type="button" class="btn btn-white" (click)="cancel()" [translate]="'common.button.cancel'">cancel</button>
					</div>
				</div>
			</form>
		</div>
	</div>
</div>
バックエンドの API と DB を生成
また、Angular Schematics を使ったスキャフォールディングは、スキーマの情報からテンプレートをもとにファイルを生成するといった意味では、フロントエンドのコードに限らず、フルスタックコードで利用できるかもしれません。DBと、サーバサイドのAPIのコードも一気に作っていきます。
フルスタックなスキーマ情報
{
	"package": "helloworld",
	"name": "customer-master",
	"name_ja_JP": "顧客マスタ",
	"templatePath": "./customer-template",
	"distPath": "./dist/master/customer",
	"fields": [
		{
			"key": "customerCode",
			"name_ja_JP": "顧客コード",
+			"db": { "type": "int(12)", "notNull": true },
+			"java": { "type": "Integer" },
			"ts": { "type": ["number"], "optional": false },
			"ui": { "type": "number", "required": true }
		},
		{
			"key": "customerName",
			"name_ja_JP": "お客様名",
+			"db": { "type": "varchar(100)", "notNull": true },
+			"java": { "type": "String" },
			"ts": { "type": ["string"], "optional": false },
			"ui": { "type": "text", "required": true }
		},
		{
			"key": "address",
			"name_ja_JP": "住所",
+			"db": { "type": "varchar(100)", "notNull": false },
+			"java": { "type": "String" },
			"ts": { "type": ["string"], "optional": true },
			"ui": { "type": "text", "required": false }
		},
		{
			"key": "tel",
			"name_ja_JP": "電話番号",
+			"db": { "type": "varchar(100)", "notNull": false },
+			"java": { "type": "String" },
			"ts": { "type": ["string"], "optional": true },
			"ui": { "type": "text", "required": false }
		}
	]
}
DDLのテンプレート
create table <%= underscore(name) %> (
	`id` varchar(100) collate utf8mb4_bin not null,
	<% fields.forEach(function(r) { %>
	`<%= r.key %>` <%= r.db.type %> collate utf8mb4_bin <%= r.db.notNull ? 'not null' : 'nul default null' %> ,
	<% }); %>
	`entry_time` timestamp null default current_timestamp,
	`entry_user` varchar(100) character set utf8mb4 collate utf8mb4_bin null default null,
	`update_time` timestamp null default current_timestamp on update current_timestamp,
	`update_user` varchar(100) character set utf8mb4 collate utf8mb4_bin null default null,
	`update_count` smallint(6) null default 0,
	primary key (id)
) ENGINE=InnoDB default CHARSET=utf8mb4 collate=utf8mb4_bin COMMENT='';
今回はマスタテーブルのオブジェクトを汎用的に大量に自動生成することが目標のため、主キーにはサロゲートキー2を使うこととします。
バックエンドのエンティティのテンプレート
package com.mcframe.labo.<%= package %>.domain.master;
import com.mcframe.labo.query.annotation.Entity;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.Id;
@Entity(table = "<%= package.charAt(0) %>m_<%= underscore(name) %>")
@Getter
@Setter
public class <%= classify(name) %> {
    @Id
    private String id;
	<% fields.forEach(function(field) { %>
	private <%= field.java.type %> <%= field.key %>;
	<% }); %>
	private String entryUser;
    private String updateUser;
    private Integer updateCount;
}
API コントローラのテンプレート
package com.mcframe.labo.<%= package %>.controller.master.<%= camelize(name) %>;
import java.util.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import com.mcframe.util.Id;
import com.mcframe.labo.util.translate.TranslateService;
import com.mcframe.labo.query.QueryEvaluator;
import com.mcframe.labo.query.QueryFactory;
import com.mcframe.labo.query.SelectResult;
import com.mcframe.labo.query.exception.*;
import com.mcframe.labo.<%= package %>.domain.master.<%= classify(name) %>;
import com.mcframe.labo.<%= package %>.notice.AppException;
import com.mcframe.labo.<%= package %>.master.base.InterceptorBase;
import com.mcframe.labo.<%= package %>.util.MasterMessage;
@RestController
@RequestMapping("api/master/<%= decamelize(name) %>")
public class <%= classify(name) %>Controller {
    private static final Q<%= classify(name) %> Q_<%= underscore(name).toUpperCase() %> = Q<%= classify(name) %>.ENTITY;
    @Autowired
    private QueryEvaluator queryEvaluator;
    @Autowired
    private QueryFactory queryFactory;
    @Autowired
    private TranslateService coreTs;
    @Autowired
    private InterceptorBase.Interceptor interceptor;
    @Transactional
    @RequestMapping(path = "/list", method = RequestMethod.GET)
    public SelectResult<<%= classify(name) %>> fetch() {
        return queryFactory
                .select(Q_<%= underscore(name).toUpperCase() %>)
                .from(Q_<%= underscore(name).toUpperCase() %>)
                .fetchWithCount();
    }
    @Transactional
    @RequestMapping(method = RequestMethod.POST)
    public Map<String, Object> insert(@RequestBody Map<String, Object> request) {
        try {
            Map<String, Object> requestRecord = queryEvaluator.convert(Q_<%= underscore(name).toUpperCase() %>.getName(), request);
            interceptor.preInsert(requestRecord);
            String <%= classify(name) %>Id = Id.getUniqueId();
            requestRecord.put(Q_<%= underscore(name).toUpperCase() %>.ID.getName(), <%= classify(name) %>Id);
            if (queryEvaluator.insert(Q_<%= underscore(name).toUpperCase() %>.getName(), requestRecord) != 1) {
                throw new AppException(MasterMessage.UNEXPECTED_DB_ERROR_OCCURRED);
            }
            return requestRecord;
        } catch (QueryEvaluationException e) {
            throw e.toAppException(coreTs, HttpStatus.BAD_REQUEST);
        }
    }
    @Transactional
    @RequestMapping(path = "/{id}", method = RequestMethod.PATCH)
    public Map<String, Object> updateById(@PathVariable(value = "id") String <%= camelize(name) %>Id, @RequestBody Map<String, Object> request) {
        try {
            Map<String, Object> requestRecord = queryEvaluator.convert(Q_<%= underscore(name).toUpperCase() %>.getName(), request);
            interceptor.preUpdateById(<%= camelize(name) %>Id, requestRecord);
            requestRecord.put(Q_<%= underscore(name).toUpperCase() %>.UPDATE_COUNT.getName(), (int) requestRecord.get(Q_<%= underscore(name).toUpperCase() %>.UPDATE_COUNT.getName()) + 1);
            if (queryEvaluator.update(Q_<%= underscore(name).toUpperCase() %>.getName(), <%= camelize(name) %>Id, requestRecord) != 1) {
                throw new AppException(MasterMessage.UNEXPECTED_DB_ERROR_OCCURRED);
            }
            return requestRecord;
        } catch (QueryEvaluationException e) {
            throw e.toAppException(coreTs, HttpStatus.BAD_REQUEST);
        }
    }
    @Transactional
    @RequestMapping(path = "/{id}", method = RequestMethod.DELETE)
    public void deleteById(@PathVariable String id) {
        try {
            queryEvaluator.delete(Q_<%= underscore(name).toUpperCase() %>.getName(), id);
        } catch (QueryEvaluationException e) {
            throw e.toAppException(coreTs, HttpStatus.BAD_REQUEST);
        }
    }
}
全てのコードを生成します。
$ node _schematics.js 
Debug mode enabled by default for local collections.
CREATE dist/master/customer/V1_Xcustomer_master.sql (778 bytes)
CREATE dist/master/customer/CustomerMaster.java (517 bytes)
CREATE dist/master/customer/CustomerMaster.ts (202 bytes)
CREATE dist/master/customer/CustomerMasterController.java (3639 bytes)
CREATE dist/master/customer/customer-master-list.component.html (1503 bytes)
CREATE dist/master/customer/customer-master-list.component.ts (4922 bytes)
CREATE dist/master/customer/customer-master-register.component.html (3439 bytes)
CREATE dist/master/customer/customer-master-register.component.ts (2536 bytes)
CREATE dist/master/customer/customer-master.service.ts (934 bytes)
DDLのクエリ
create table customer_master (
	`id` varchar(100) collate utf8mb4_bin not null,
	`customerCode` int(12) collate utf8mb4_bin not null ,	
	`customerName` varchar(100) collate utf8mb4_bin not null ,
	`address` varchar(100) collate utf8mb4_bin nul default null ,	
	`tel` varchar(100) collate utf8mb4_bin nul default null ,
	`entry_time` timestamp null default current_timestamp,
	`entry_user` varchar(100) character set utf8mb4 collate utf8mb4_bin null default null,
	`update_time` timestamp null default current_timestamp on update current_timestamp,
	`update_user` varchar(100) character set utf8mb4 collate utf8mb4_bin null default null,
	`update_count` smallint(6) null default 0,
	primary key (id)
) ENGINE=InnoDB default CHARSET=utf8mb4 collate=utf8mb4_bin COMMENT='';
バックエンドのエンティティのプログラム
package com.mcframe.labo.helloworld.domain.master;
import com.mcframe.labo.query.annotation.Entity;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.Id;
@Entity(table = "hm_customer_master")
@Getter
@Setter
public class CustomerMaster {
    @Id
    private String id;
	private Integer customerCode;
	private String customerName;
	private String address;
	private String tel;	
	private String entryUser;
    private String updateUser;
    private Integer updateCount;
}
API コントローラのプログラム
package com.mcframe.labo.helloworld.controller.master.customerMaster;
import java.util.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import com.mcframe.util.Id;
import com.mcframe.labo.util.translate.TranslateService;
import com.mcframe.labo.query.QueryEvaluator;
import com.mcframe.labo.query.QueryFactory;
import com.mcframe.labo.query.SelectResult;
import com.mcframe.labo.query.exception.*;
import com.mcframe.labo.helloworld.domain.master.CustomerMaster;
import com.mcframe.labo.helloworld.notice.AppException;
import com.mcframe.labo.helloworld.master.base.InterceptorBase;
import com.mcframe.labo.helloworld.util.MasterMessage;
@RestController
@RequestMapping("api/master/customer-master")
public class CustomerMasterController {
    private static final QCustomerMaster Q_CUSTOMER_MASTER = QCustomerMaster.ENTITY;
    @Autowired
    private QueryEvaluator queryEvaluator;
    @Autowired
    private QueryFactory queryFactory;
    @Autowired
    private TranslateService coreTs;
    @Autowired
    private InterceptorBase.Interceptor interceptor;
    @Transactional
    @RequestMapping(path = "/list", method = RequestMethod.GET)
    public SelectResult<CustomerMaster> fetch() {
        return queryFactory
                .select(Q_CUSTOMER_MASTER)
                .from(Q_CUSTOMER_MASTER)
                .fetchWithCount();
    }
    @Transactional
    @RequestMapping(method = RequestMethod.POST)
    public Map<String, Object> insert(@RequestBody Map<String, Object> request) {
        try {
            Map<String, Object> requestRecord = queryEvaluator.convert(Q_CUSTOMER_MASTER.getName(), request);
            interceptor.preInsert(requestRecord);
            String CustomerMasterId = Id.getUniqueId();
            requestRecord.put(Q_CUSTOMER_MASTER.ID.getName(), CustomerMasterId);
            if (queryEvaluator.insert(Q_CUSTOMER_MASTER.getName(), requestRecord) != 1) {
                throw new AppException(MasterMessage.UNEXPECTED_DB_ERROR_OCCURRED);
            }
            return requestRecord;
        } catch (QueryEvaluationException e) {
            throw e.toAppException(coreTs, HttpStatus.BAD_REQUEST);
        }
    }
    @Transactional
    @RequestMapping(path = "/{id}", method = RequestMethod.PATCH)
    public Map<String, Object> updateById(@PathVariable(value = "id") String customerMasterId, @RequestBody Map<String, Object> request) {
        try {
            Map<String, Object> requestRecord = queryEvaluator.convert(Q_CUSTOMER_MASTER.getName(), request);
            interceptor.preUpdateById(customerMasterId, requestRecord);
            requestRecord.put(Q_CUSTOMER_MASTER.UPDATE_COUNT.getName(), (int) requestRecord.get(Q_CUSTOMER_MASTER.UPDATE_COUNT.getName()) + 1);
            if (queryEvaluator.update(Q_CUSTOMER_MASTER.getName(), customerMasterId, requestRecord) != 1) {
                throw new AppException(MasterMessage.UNEXPECTED_DB_ERROR_OCCURRED);
            }
            return requestRecord;
        } catch (QueryEvaluationException e) {
            throw e.toAppException(coreTs, HttpStatus.BAD_REQUEST);
        }
    }
    @Transactional
    @RequestMapping(path = "/{id}", method = RequestMethod.DELETE)
    public void deleteById(@PathVariable String id) {
        try {
            queryEvaluator.delete(Q_CUSTOMER_MASTER.getName(), id);
        } catch (QueryEvaluationException e) {
            throw e.toAppException(coreTs, HttpStatus.BAD_REQUEST);
        }
    }
}
これでRDBのデータを操作する単純な画面を大量生産できるようになりました。
おわりに
今回は Angular Schematics を使ってフルスタックのコードを生成するジェネレータを作成しました。しかし、今はまだ手の込んだ実装を行う前の初回に一度だけ生成するいわゆる消極的なコードジェネレータ3です。Schematics を使って、既存のプログラムに変更を加えるような実装もできるようですが、公式リファレンスに具体的な内容がなく、もうちょっと内部を調査する必要がありそうです。また今回はバックエンドは Spring Framework を使ったので Spring Data JDBC や、他の構成の際は ASP.NET などでドメインモデルをスキャフォールディングした方が相性が良いかもしれません。他にも、昨今はChatGPT や Copilot といった生成AIが柔軟なコードを生成することができるため、テンプレートを用いた生成はコーディング規約などプライベートな知識を含める形で使い分けるといった棲み分けが必要です。
種々の手段を使いこなし、開発の生産性を上げていけたらと思います。
今回の検証環境
作業PCのOS
macOS Monterey バージョン 12.7.1
Angular関連のOSS
% ng version
     _                      _                 ____ _     ___
    / \   _ __   __ _ _   _| | __ _ _ __     / ___| |   |_ _|
   / △ \ | '_ \ / _` | | | | |/ _` | '__|   | |   | |    | |
  / ___ \| | | | (_| | |_| | | (_| | |      | |___| |___ | |
 /_/   \_\_| |_|\__, |\__,_|_|\__,_|_|       \____|_____|___|
                |___/
    
Angular CLI: 15.2.10
Node: 18.10.0
Package Manager: npm 8.19.2
OS: darwin x64
Angular: 
... 
Package                      Version
------------------------------------------------------
@angular-devkit/architect    0.1502.10 (cli-only)
@angular-devkit/core         15.2.10 (cli-only)
@angular-devkit/schematics   15.2.10 (cli-only)
@schematics/angular          15.2.10 (cli-only)
参考記事
- 
DRY原則
DRY(Don't Repeat Yourself)原則は、ソフトウェア開発における基本的な原則の一つです。この原則は、同じコードや情報を複数の場所に重複させないようにすることを提唱しています。DRY原則を守ることで、コードの保守性が向上し、変更が発生した際に修正が容易になります。 ↩ - 
サロゲートキー
対象のデータと直接関係のない意味を持たない主キー ↩ - 
消極的なコードジェネレータ
タイピング量を削減するためにパラメータ化されたテンプレートで指定通りの出力をするもの。これに対して、利便性を追求すため、必要に応じでプログラムを何度も再生成できるものは積極的なコードジェネレータと呼ばれる。 ↩ 
