Skip to content

Commit 01cfd8e

Browse files
committed
refactor(articles): reorganize files and add architecture documentation
1 parent 44d5567 commit 01cfd8e

File tree

5 files changed

+194
-75
lines changed

5 files changed

+194
-75
lines changed

ARCHITECTURE.md

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Architecture
2+
3+
## Overview
4+
5+
This service uses a **Layered Architecture** to keep our code clean, clear, and easy to maintain.
6+
7+
We mostly follow the [NestJS philosophy](https://docs.nestjs.com/#philosophy), but interpret it to fit our needs.
8+
9+
We separate the system into 3 layers:
10+
11+
1. **Controller** – Talks to the client
12+
2. **Service** – Handles the business logic
13+
3. **Repository** – Talks to the database
14+
15+
### 1. Controller Layer (Client-facing)
16+
17+
- Receives data from the client (DTO)
18+
- Returns data to the client (DTO)
19+
- Validates data types
20+
- Calls the service layer
21+
- Can shape requests and responses, without performing any business logic
22+
23+
---
24+
25+
### 2. Service Layer (Business Logic)
26+
27+
- Contains the business logic
28+
- Can perform any kind of calculation or transformation as long as it's part of the business rules
29+
- Validates logic rules (e.g., checking if a user can register)
30+
- Handles errors and logging
31+
- Calls the repository layer to get or save data
32+
33+
---
34+
35+
### 3. Repository Layer (Database Access)
36+
37+
- Talks to the database
38+
- Only responsible for saving and retrieving data
39+
- **No** assumptions about validation
40+
- **No** business logic should go here
41+
42+
## Types we use
43+
44+
> [!NOTE]
45+
> We will use the `User` entity as an example for the rest of this document.
46+
47+
To keep things clear & scalable, we separate each "entity" into three types:
48+
49+
| Type | Associated Layer | Purpose |
50+
| --------------------------------------------------- | ---------------- | ---------------------------------------------- |
51+
| `CreateUserDto`, `UpdateUserDto`, `UserResponseDto` | Controller | Used to talk with the client |
52+
| `IUser` | All | Common contract shared between layers |
53+
| `User` | Repository | Defines how the data is stored in the database |
54+
55+
## Type Design Principles
56+
57+
1. **Interfaces vs Classes**:
58+
- Use interfaces (`IUser`) to define contracts between layers
59+
- Use classes (`User`) for concrete implementations. The (database) entity is a concrete implementation of the interface.
60+
- This separation allows for better testing and flexibility
61+
62+
2. **Canonical Forms**:
63+
- Store canonical forms in the database (e.g., `birthdate`)
64+
- The canonical form is represented in the entity (`User`) *and* the interface (`IUser`)
65+
- The DTO might use a different form, e.g. `CreateUserDto` might use `age` instead of `birthdate`
66+
- Use mappers to convert between forms
67+
68+
3. **System vs Domain Properties**:
69+
- System properties (`id`, `createdAt`, `updatedAt`) are managed by the base entity
70+
- Domain properties (e.g. `email`, `name`) are defined in the interface, enforced by the entity, and controlled by the DTOs
71+
72+
## Examples
73+
74+
### Example 1: Can register?
75+
76+
```typescript
77+
canRegister(user: Partial<IUser>) {
78+
if (user.email.endsWith('@banned.com')) {
79+
throw new ForbiddenException('Email domain is not allowed');
80+
}
81+
82+
if (!this.isOldEnough(user.birthdate)) {
83+
throw new ForbiddenException('User must be at least 13 years old');
84+
}
85+
}
86+
```
87+
88+
This check lives in the service layer because:
89+
90+
- It's business logic
91+
- It could change based on product decisions
92+
- It might be reused across different controllers (`signup`, `adminCreateUser`, etc.)
93+
- If tomorrow we add GraphQL on top of our REST, this logic will remain the same
94+
95+
### Example 2: Normalize email
96+
97+
```typescript
98+
normalizeEmail(email: string) {
99+
return email.toLowerCase().trim();
100+
}
101+
```
102+
103+
Also clearly service-level: it’s a standardized rule, not controller-specific logic.
104+
105+
## See also
106+
107+
- **Project structure** - see [Project Structure](#TODO)
108+
- **Contributing** - see [Developer's Guide](CONTRIBUTING.md)
109+
- **Diagrams** - see [Diagrams](#TODO)
110+
- **Documentation (for consumers)** - see [RealWorld Backend Specifications](https://realworld-docs.netlify.app/specifications/backend/introduction/)
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
ReturnedCommentsResponse,
1717
} from './comments/comments.schema';
1818

19-
export const articlesPlugin = new Elysia().use(setupArticles).group(
19+
export const articlesController = new Elysia().use(setupArticles).group(
2020
'/articles',
2121
{
2222
detail: {

src/articles/articles.schema.ts

Lines changed: 0 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -8,29 +8,7 @@ import { articles, type favoriteArticles } from './articles.model';
88
export const insertArticleSchemaRaw = createInsertSchema(articles);
99
export const selectArticleSchemaRaw = createSelectSchema(articles);
1010

11-
export const InsertArticleSchema = Type.Object({
12-
article: Type.Composite([
13-
Type.Pick(insertArticleSchemaRaw, ['title', 'description', 'body']),
14-
Type.Object({ tagList: Type.Optional(Type.Array(Type.String())) }),
15-
]),
16-
});
1711

18-
export type ArticleToCreateData = Static<typeof InsertArticleSchema>['article'];
19-
export type ArticleToCreate = Omit<ArticleToCreateData, 'tagList'> & {
20-
authorId: number;
21-
slug: string;
22-
};
23-
24-
export const UpdateArticleSchema = Type.Object({
25-
article: Type.Composite([
26-
Type.Partial(
27-
Type.Pick(insertArticleSchemaRaw, ['title', 'description', 'body']),
28-
),
29-
Type.Object({
30-
tagList: Type.Optional(Type.Array(Type.String())),
31-
}),
32-
]),
33-
});
3412

3513
export type ArticleToUpdateRequest = Static<
3614
typeof UpdateArticleSchema
@@ -39,29 +17,6 @@ export type ArticleToUpdate = Omit<ArticleToUpdateRequest, 'tagList'> & {
3917
slug: string;
4018
};
4119

42-
export const ReturnedArticleSchema = Type.Composite([
43-
Type.Omit(selectArticleSchemaRaw, ['id', 'authorId']),
44-
Type.Object({
45-
author: Type.Object({
46-
username: Type.String(),
47-
bio: Type.String(),
48-
image: Type.String(),
49-
following: Type.Boolean(),
50-
}),
51-
favorited: Type.Boolean(),
52-
favoritesCount: Type.Number(),
53-
}),
54-
Type.Object({ tagList: Type.Array(Type.String()) }),
55-
]);
56-
57-
export const ReturnedArticleResponseSchema = Type.Object({
58-
article: ReturnedArticleSchema,
59-
});
60-
61-
export type ReturnedArticle = Static<typeof ReturnedArticleSchema>;
62-
export type ReturnedArticleResponse = Static<
63-
typeof ReturnedArticleResponseSchema
64-
>;
6520

6621
export type ArticleInDb = Omit<
6722
typeof articles.$inferSelect,
@@ -74,33 +29,4 @@ export type ArticleInDb = Omit<
7429

7530
export type ArticleFavoritedBy = typeof favoriteArticles.$inferSelect;
7631

77-
export const ArticleFeedQuerySchema = Type.Object({
78-
limit: Type.Optional(
79-
Type.Number({
80-
minimum: 1,
81-
maximum: MAX_PAGINATION_LIMIT,
82-
default: 20,
83-
}),
84-
),
85-
offset: Type.Optional(Type.Number({ minimum: 0, default: 0 })),
86-
});
87-
export const ListArticlesQuerySchema = Type.Composite([
88-
ArticleFeedQuerySchema,
89-
Type.Object({
90-
tag: Type.Optional(Type.String()),
91-
author: Type.Optional(Type.String()),
92-
favorited: Type.Optional(Type.String()),
93-
}),
94-
]);
95-
96-
export const ReturnedArticleListSchema = Type.Object({
97-
articles: Type.Array(Type.Omit(ReturnedArticleSchema, ['body'])),
98-
articlesCount: Type.Number(),
99-
});
100-
101-
export type ReturnedArticleList = Static<typeof ReturnedArticleListSchema>;
10232

103-
export const DeleteArticleResponse = Type.Object({
104-
message: Type.String(),
105-
slug: Type.String(),
106-
});

src/articles/dto/article.dto.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { Type, Static } from '@sinclair/typebox';
2+
3+
// DTO for creating an article
4+
export const CreateArticleDto = Type.Object({
5+
title: Type.String({ minLength: 1 }),
6+
description: Type.String({ minLength: 1 }),
7+
body: Type.String({ minLength: 1 }),
8+
slug: Type.String({ minLength: 1 }),
9+
tagList: Type.Optional(Type.Array(Type.String())),
10+
});
11+
export type CreateArticleDto = Static<typeof CreateArticleDto>;
12+
13+
// DTO for updating an article
14+
export const UpdateArticleDto = Type.Object({
15+
title: Type.Optional(Type.String({ minLength: 1 })),
16+
description: Type.Optional(Type.String({ minLength: 1 })),
17+
body: Type.Optional(Type.String({ minLength: 1 })),
18+
tagList: Type.Optional(Type.Array(Type.String())),
19+
});
20+
export type UpdateArticleDto = Static<typeof UpdateArticleDto>;
21+
22+
// DTO for article response
23+
export const ArticleResponseDto = Type.Object({
24+
slug: Type.String(),
25+
title: Type.String(),
26+
description: Type.String(),
27+
body: Type.String(),
28+
tagList: Type.Array(Type.String()),
29+
createdAt: Type.String(),
30+
updatedAt: Type.String(),
31+
favorited: Type.Boolean(),
32+
favoritesCount: Type.Number(),
33+
author: Type.Object({
34+
username: Type.String(),
35+
bio: Type.String(),
36+
image: Type.String(),
37+
following: Type.Boolean(),
38+
}),
39+
});
40+
export type ArticleResponseDto = Static<typeof ArticleResponseDto>;
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// drizzle-orm Article entity moved from articles.model.ts
2+
import { relations, sql } from 'drizzle-orm';
3+
import {
4+
integer,
5+
pgTable,
6+
primaryKey,
7+
serial,
8+
text,
9+
timestamp,
10+
} from 'drizzle-orm/pg-core';
11+
import { articleTags } from '@/tags/tags.model';
12+
import { users } from '@/users/users.model';
13+
import { favoriteArticles, comments } from '../articles.model';
14+
15+
export const articles = pgTable('articles', {
16+
id: serial('id').primaryKey(),
17+
slug: text('slug').notNull().unique(),
18+
title: text('title').notNull(),
19+
description: text('description').notNull(),
20+
body: text('body').notNull(),
21+
createdAt: timestamp('created_at').defaultNow().notNull(),
22+
updatedAt: timestamp('updated_at').defaultNow().notNull(),
23+
authorId: integer('author_id')
24+
.references(() => users.id, { onDelete: 'cascade' })
25+
.notNull(),
26+
});
27+
28+
export const articleRelations = relations(articles, ({ one, many }) => ({
29+
author: one(users, {
30+
fields: [articles.authorId],
31+
references: [users.id],
32+
relationName: 'author',
33+
}),
34+
favoritedBy: many(favoriteArticles, {
35+
relationName: 'favoriteArticle',
36+
}),
37+
comments: many(comments, {
38+
relationName: 'articleComments',
39+
}),
40+
tags: many(articleTags, {
41+
relationName: 'articleTags',
42+
}),
43+
}));

0 commit comments

Comments
 (0)