타입스크립트에서 도메인 엔티티를 만들기 위한 시도들을 정리해둔 글입니다.
만들고자 했던 클래스의 기능은 다음과 같습니다.
타입 안정성
클래스 외부에서 클래스 속성의 직접 변경이 불가능해야 합니다.
메소드를 통해서만 변경 가능합니다.
serviceMethod() {
const article = new Article(...)
// ❌ 직접 변경시 타입 오류 발생해야함
article.title = 'newTitle'
// ✅ 메소드를 통해 변경 가능
article.changeTitle('newTitle')
}
모든 속성 값을 읽을 수 있어야 합니다.
필요한 값을 함수로 추출하는 것과 별개로 DB 저장을 위해 모든 값을 가져올 수 있어야 합니다.
불완전한 객체를 생성하지 못하도록 합니다
// ❌ 전체 속성을 가진 완전한 객체만 만들 수 있어야 합니다.
const article = new Article()
// ✅ 생성자 또는 팩토리 함수에서 모든 속성이 만들어져야 합니다.
const article = Article.of({ ... })
const article = new Article({ ... })
(optional) key-value 형식으로 인스턴스 생성이 가능하도록 합니다.
// ❌
new Article('id', 'title', 'contents')
// ✅
new Article({ id: 'id', title: 'title', contents: 'contents' })
게시글(Article) 모델을 예시로 만들어봅니다.
팩토리 메소드 create()와 changeTitle() 메소드만 가지고 있는 단순한 엔티티입니다.
type ArticleProps = {
id: string
title: string
contents: string
updatedAt: Date
createdAt: Date
}
export class Article {
constructor(
public readonly id: string,
private title: string,
private contents: string,
private createdAt: Date,
private updatedAt: Date,
) {}
static create(
args: Pick<ArticleProps, 'id' | 'title' | 'contents'>,
current = new Date(),
) {
return new Article(
args.id,
args.title,
args.contents,
current,
current,
);
}
changeTitle(title: string, current = new Date()) {
this.title = title;
this.updatedAt = current;
}
toPayload(): Readonly<ArticleProps> {
return {
id: this.id,
title: this.title,
contents: this.contents,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
};
}
}
타입스크립트에서 private(or #) 으로 정의한 속성은 type에 표시되지 않습니다.
외부 변경을 막기위해 모든 속성을 private으로 하고,
DB 저장을 위한 toPayload() 함수를 만들어주었습니다.
가진 기능이 얼마 없음에도 클래스의 크기가 큽니다.
모든 속성을 private 으로 만들기보다, public readonly로 설정해서 외부 변경을 막아봅니다.
export type Mut<T> = {
-readonly [Key in keyof T]: T[Key]
}
type ArticleProps = NonFunctionProps<Article>;
export class Article {
constructor(
public readonly id: string,
public readonly title: string,
public readonly contents: string,
public readonly updatedAt: Date,
public readonly createdAt: Date,
) {}
static create(
args: Omit<ArticleProps, 'updatedAt' | 'createdAt'>,
current = new Date(),
) {
return new Article(
args.id,
args.title,
args.contents,
current,
current,
);
}
changeTitle(
this: Mut<Article>,
title: string,
current = new Date(),
) {
this.title = title;
this.updatedAt = current;
}
}
러스트(&mut self) 참고했습니다.
모든 속성을 public readonly 로 설정해서 외부에서 조회할 수 있도록 하고,
클래스 내부에서는 this에서 readonly 제거하여 값의 변경이 가능합니다.
public 으로 설정하여 속성들이 type에 표시되고, 조회도 가능하므로 toPayload 함수도 필요 없습니다.
원하는 기능들이 어느정도 구현된 상태입니다.
실제로 변하지 않는 속성을 구분할 수 없는 단점이 있습니다.
클래스 상속으로 해결해봅니다.
export abstract class BaseEntity<Props> {
readonly #props: Props;
constructor(
props: Props,
) {
this.#props = { ...props };
}
protected get props(): Props {
return this.#props;
}
toPayload(): Readonly<Props> {
return { ...this.#props };
}
}
interface ArticleProps {
readonly id: string;
title: string;
contents: string;
updatedAt: Date;
readonly createdAt: Date;
}
class Article extends BaseEntity<ArticleProps> {
changeTitle(
title: string,
current = new Date(),
) {
this.props.title = title;
this.props.updatedAt = current;
}
static create(
args: Omit<ArticleProps, 'createdAt' | 'updatedAt'>,
current = new Date(),
) {
return new Article({
...args,
updatedAt: current,
createdAt: current,
});
}
}
많이 사용되는 패턴입니다.
속성 인터페이스가 클래스 외부로 나오면서 클래스에서는 메소드만 남았습니다.
protected 접근자의 props를 통해 내부에서만 값의 수정이 가능합니다.
클래스 외부에서는 toPayload()를 통해 모든 속성을 조회할 수 있습니다.
프로퍼티에 접근할 때마다 props 에서 가져와야 하지만 원하는 기능이 모두 구현되었습니다.
this.props.title 말고 this.title로 접근할 수 있을까
export const BaseEntity: new <Props extends Record<string, any>>(args: Props) => Props =
class {
constructor(args: any) {
Object.assign(this, args);
}
} as any;
interface ArticleProps {
readonly id: string;
title: string;
contents: string;
updatedAt: Date;
readonly createdAt: Date;
}
class Article extends BaseEntity<ArticleProps> {
changeTitle(
title: string,
current = new Date(),
) {
this.title = title;
this.updatedAt = current;
}
static create(
args: Omit<ArticleProps, 'updatedAt' | 'createdAt'>,
current = new Date(),
) {
return new Article({
...args,
updatedAt: current,
createdAt: current,
});
}
}
상속을 사용하면서 props 를 안써보려고 시도한 방법입니다.
타입으로 만들었기 때문에 각 속성에 protected를 사용할 수 없고, 클래스 외부에서도 변경이 가능해졌습니다.
모든 속성을 readonly로 설정할 수도 있지만, 그러면 클래스 내부에서도 변경이 불가능 해집니다.
타입스크립트에서는 클래스의 속성을 외부에서만 readonly로 설정하는 기능이 없습니다.
하지만 미리 만들어진 클래스를 변경할 수는 있습니다.
export const makeReadonly: <T extends ClassType<any>>(args: T)
=> new (...args: ConstructorParameters<T>)
=> Readonly<InstanceType<T>> =
(baseClass: ClassType<any>) => baseClass;
type ArticleProps = NonFunctionProps<Article>;
class Article extends makeReadonly(class {
constructor(
public readonly id: string,
public title: string,
public contents: string,
public updatedAt: Date,
public readonly createdAt: Date,
) {}
changeTitle(
title: string,
current = new Date(),
) {
this.title = title;
this.updatedAt = current;
}
}) {
static create(
args: Omit<ArticleProps, 'updatedAt' | 'createdAt'>,
current = new Date(),
) {
return new Article(
args.id,
args.title,
args.contents,
current,
current,
);
}
}
이미 만들어진 클래스에 함수를 래핑해서 외부에서는 readonly가 되도록 하였습니다.
래핑함수로 뭔가 할 수 있다는걸 알았으니 추가적으로 기능을 붙여봅니다
export const makeEntity: <T>(args: ClassType<T>) => new (args: NonFunctionProps<T>) => Readonly<T> =
(entityClass: any) =>
class extends entityClass {
constructor(args: any) {
super();
Object.assign(this, args);
}
} as any;
type ArticleProps = NonFunctionProps<Article>;
class Article extends makeEntity(class {
public readonly id!: string;
public title!: string;
public contents!: string;
public updatedAt!: Date;
public readonly createdAt!: Date;
changeTitle(
title: string,
current = new Date(),
) {
this.title = title;
this.updatedAt = current;
}
}) {
static create(
args: Omit<ArticleProps, 'updatedAt' | 'createdAt'>,
current = new Date(),
) {
return new Article({
...args,
updatedAt: current,
createdAt: current,
});
}
}
기존 클래스를 래핑해서 클래스 외부에서 readonly를 붙여주고, 생성자를 Key-Value 형식으로 만들어주었습니다.
원하는 기능은 모두 구현됬지만, 일반적으로 사용되는 문법이 아니라 사용이 꺼려집니다.
이제 클래스를 사용하지 않고, 불변의 인터페이스를 활용합니다.
interface Article {
readonly id: string;
readonly title: string;
readonly contents: string;
readonly updatedAt: Date;
readonly createdAt: Date;
}
namespace Article {
export const of =
(self: Article): Article =>
self;
export const changeTitle =
(title: string, current = new Date()) =>
(self: Article) =>
of({
...self,
title,
updatedAt: current
});
export const create =
(
args: Omit<Article, 'updatedAt' | 'createdAt'>,
current = new Date(),
) =>
of({
...args,
updatedAt: current,
createdAt: current,
});
}
const article = Article.create({ id: 'id', title: 'title', contents: 'contents' });
// effect-ts의 pipe 사용
const changedArticle = pipe(
article,
Article.changeTitle('newTitle'),
);
도메인을 불변으로 정의하고, 변경이 필요한 경우 새로운 객체를 생성합니다.
네임스페이스를 통해 클래스의 정적 메소드처럼 사용할 수 있습니다.
클래스를 상속하거나, 생성자를 정의하는 등의 복잡한 방법이 없이 직관적인 것이 장점입니다.
객체가 수정되지 않으므로 실수할 가능성도 줄어듭니다.
Effect-TS와 같은 함수형 라이브러리를 사용하는 경우 적용할만한 방법입니다.
const propsWithOther = {
id: 'id',
title: 'title',
contents: 'contents',
changeTitle: 123
}
const article = Article.create(propsWithOther);
article.changeTitle; // 123
const props = {
id: 'id',
get title() {
return 'title'
},
contents: 'contents'
}
const article = Article.create({...props})
article.title // undefined
두 경우 모두 생성 메소드에 key-value를 직접 입력해주어야 문제가 없습니다.
const param = someMethod()
// ❌ 외부에서 가져온 값을 그대로 입력하지 않습니다.
Article.create(param)
// ✅ key-value 형태로 입력합니다.
Article.create({
id: param.id,
title: param.title,
contents: param.contents,
})
자바의 lombok 처럼 깔끔하게 해결되지 않습니다.
상속을 사용한 2번 방법이 실무에서 사용하기에 안정적으로 보입니다.
모든 코드는 GitHub 에 올려두었습니다.