MongoDB: Express/Mongoose統合:アプリケーションアーキテクチャ
最終更新:2026-08-26
ExpressとMongooseの統合は本格的なNode.jsアプリケーション開発の第一歩—マスターすれば堅牢なアプリケーションアーキテクチャを構築できます。
1. 学習内容
- Express + Mongoose統合パターン
- プロジェクト構造規約
- 接続管理(接続プール、切断)
- スキーマ設計のベストプラクティス
- ミドルウェアとフック
- Expressルーティングとコントローラー
graph TB
Client[クライアント] -->|HTTP Request| Express[Express Server]
subgraph "ミドルウェアスタック"
Express --> MW1[認証ミドルウェア]
MW1 --> MW2[バリデーション]
MW2 --> MW3[エラーハンドリング]
end
MW3 --> Router[ルーター]
Router --> Ctrl[コントローラー]
Ctrl --> Model[Mongoose Model]
Model --> DB[(MongoDB)]
DB -->|データ| Model
Model -->|JSON| Client
style Express fill:#d4edda
style Model fill:#cce5ff
style DB fill:#fff3cd
2. プロジェクト構造規約
概念概要: 優れたプロジェクト構造は「関心の分離」原則に従う必要があります—ルーティング(URLマッピング)、コントローラー(リクエスト処理)、モデル(データ定義)が明確に分離され、相互に依存しすぎないようにします。これにより、各層を独立して変更・テストできます。
MVCパターンの適用: Express + Mongooseプロジェクトでは、MVCパターンを緩やかに適用します—Model(Mongooseスキーマ)がデータ構造と検証を担当、View(JSONレスポンス)がAPIデータを担当、Controller(リクエスト処理関数)がビジネスロジックを担当します。
ディレクトリ構造設計の原則:
| ディレクトリ | 責務 | 依存方向 | テスト戦略 |
|---|---|---|---|
models/ |
スキーマ定義 + 検証 | 外部依存なし | ユニットテスト |
controllers/ |
リクエスト処理 + 調整 | modelsに依存 | 統合テスト |
routes/ |
URL → コントローラーマッピング | controllers + middlewaresに依存 | ルートテスト |
middlewares/ |
認証/認可/エラー処理 | configに依存 | ユニットテスト |
validators/ |
joiスキーマ定義 | 外部依存なし | ユニットテスト |
utils/ |
ユーティリティ関数 | 外部依存なし | ユニットテスト |
shophub-api/
├── package.json
├── .env # 環境変数(非Git管理)
├── .env.example # 環境変数テンプレート
├── src/
│ ├── app.js # Expressアプリケーション
│ ├── config/
│ │ └── db.js # Mongoose接続
│ ├── models/ # データモデル
│ │ ├── User.js
│ │ ├── Product.js
│ │ └── Review.js
│ ├── controllers/ # ビジネスロジック
│ │ ├── authController.js
│ │ ├── productController.js
│ │ └── reviewController.js
│ ├── routes/ # ルーティング
│ │ ├── auth.js
│ │ ├── products.js
│ │ └── reviews.js
│ ├── middlewares/ # ミドルウェア
│ │ ├── auth.js # JWT認証
│ │ ├── errorHandler.js
│ │ └── validate.js
│ ├── validators/ # リクエスト検証
│ │ └── schemas.js
│ └── utils/ # ユーティリティ
│ ├── logger.js
│ └── jwt.js
└── README.md
ポイント解説:
.envをGit管理から除外し、機密情報(DBパスワード、JWT鍵)を保護。models/は他のモジュールに依存せず、テストが容易。controllers/はリクエスト/レスポンス処理に集中し、ビジネスロジックはservices/に抽出可能。
3. 接続管理
概念説明: Mongoose接続はアプリケーションの「生命線」です。適切な接続管理には以下が含まれます:接続プールサイズの設定、接続タイムアウト、再接続戦略、エラーハンドリング、およびアプリケーションシャットダウン時の接続クローズ。これらを設定することで、高同時実行シナリオで接続を使い果たすことや、ネットワーク揺らぎによるサービス停止を防止できます。
接続プールの原理: Node.jsはシングルスレッドですが、データベース操作は非同期で実行されるため、複数のリクエストが同時にデータベースへの接続を必要とします。接続プールはあらかじめ一定数の接続を作成して保持し、リクエストごとに接続を確立するのではなく、プールから接続を「貸し出し」、使用後に「返却」します。接続プールサイズが小さすぎるとリクエストが待機し、大きすぎるとデータベースリソースを浪費します。
接続プールの動作:
graph LR
subgraph "Node.jsアプリケーション"
R1[リクエスト1] -->|貸出| Pool[(接続プール<br/>min=5 max=50)]
R2[リクエスト2] -->|貸出| Pool
R3[リクエスト3] -->|待機| Queue[待機キュー<br/>waitQueueTimeoutMS]
end
Pool -->|アクティブ接続| Active[mongod Primary]
Pool -->|アイドル接続| Idle[アイドル回収<br/>maxIdleTimeMS]
R1 -.->|返却| Pool
R3 -.->|返却接続を取得| Pool
style Pool fill:#d4edda
style Queue fill:#fff3cd
style Active fill:#cce5ff
接続設定パラメータガイド:
| パラメータ | デフォルト値 | チューニング戦略 | 高すぎるリスク | 低すぎるリスク |
|---|---|---|---|---|
| maxPoolSize | 100 | ピーク同時接続数 × 1.5 | DB接続を使い果たす | リクエストキューがタイムアウト |
| minPoolSize | 0 | ピークの10%に設定 | 起動が遅い、メモリ使用量大 | コールドスタート遅延 |
| maxIdleTimeMS | 0 | 30000(30秒) | 頻繁に新規接続作成 | 接続リーク |
| waitQueueTimeoutMS | 0 | 10000(10秒) | リクエストバックログ | リクエストが永久待機 |
| serverSelectionTimeoutMS | 30000 | 5000 | 長い遅延 | 高速失敗 |
| connectTimeoutMS | 30000 | 10000 | 接続確立が遅い | 接続失敗 |
接続数計算式: maxPoolSize = ピーク同時接続数 * 平均クエリ時間(秒) * 安全係数(1.5)
// === Mongoose接続設定 ===
const mongoose = require('mongoose');
mongoose.connect(process.env.MONGODB_URI, {
maxPoolSize: 50, // 最大接続数(デフォルト100)
minPoolSize: 5, // 最小接続数(デフォルト0)
maxIdleTimeMS: 30000, // 接続アイドルタイムアウト(デフォルト無制限)
waitQueueTimeoutMS: 10000, // 接続待機タイムアウト
serverSelectionTimeoutMS: 5000, // サーバー選択タイムアウト
socketTimeoutMS: 45000, // ソケットタイムアウト
family: 4 // IPv4を使用
})
.then(() => console.log('MongoDB接続成功'))
.catch(err => console.error('MongoDB接続エラー:', err));
// === 接続イベント監視 ===
mongoose.connection.on('connected', () => {
console.log('Mongooseが接続されました');
});
mongoose.connection.on('error', (err) => {
console.error('Mongoose接続エラー:', err);
});
mongoose.connection.on('disconnected', () => {
console.log('Mongoose接続が切断されました');
});
// === アプリケーション終了時に接続をクローズ ===
process.on('SIGINT', async () => {
await mongoose.connection.close();
console.log('MongoDB接続を閉じました');
process.exit(0);
});
4. スキーマ設計のベストプラクティス
概念説明: Mongooseスキーマは単なる「データ構造定義」ではなく、検証ルール、インデックス、仮想フィールド、ミドルウェアを含む「データ契約」です。優れたスキーマ設計は、アプリケーション層でのデータ整合性を保証し、無効データがデータベースに到達するのを防ぎます。
スキーマ設計の決定事項:
| 設計上の決定 | 選択肢 | 推奨 | 理由 |
|---|---|---|---|
| パスワード保存 | 平文 / ハッシュ | passwordHash + select: false |
デフォルトクエリで返さず漏洩防止 |
| タイムスタンプ | 手動管理 / 自動 | timestamps: true |
createdAt/updatedAtを自動管理 |
| 削除戦略 | 物理削除 / ソフト削除 | isDeleted + pre-findフィルタ |
データ復旧可能、コンプライアンス要件 |
| 数値精度 | Number / Decimal128 | Decimal128(金額) | 浮動小数点精度問題を回避 |
| 列挙値 | 文字列 / enum | enum配列 + デフォルト |
無効値を防止 |
| 一意制約 | アプリ層 / DB層 | unique: true + インデックス |
データベースレベルで一意性を保証 |
(1) 基本的なスキーマ定義
const UserSchema = new mongoose.Schema({
email: {
type: String,
required: [true, 'メールアドレスは必須です'],
unique: true,
lowercase: true,
trim: true,
match: [/^\S+@\S+\.\S+$/, '無効なメール形式です']
},
username: {
type: String,
required: true,
unique: true,
minlength: 3,
maxlength: 30,
match: [/^[a-zA-Z0-9_]+$/, '英数字とアンダースコアのみ']
},
passwordHash: {
type: String,
required: true,
minlength: 60, // bcryptハッシュ長
select: false // デフォルトで返さない
},
role: {
type: String,
enum: ['customer', 'admin', 'moderator'],
default: 'customer'
},
isActive: {
type: Boolean,
default: true
}
}, {
timestamps: true, // createdAt, updatedAtを自動管理
toJSON: { virtuals: true }, // JSONシリアライズ時に仮想フィールドを含める
toObject: { virtuals: true }
});
// インデックス作成
UserSchema.index({ email: 1 }, { unique: true });
UserSchema.index({ username: 1 }, { unique: true });
(2) ソフト削除パターン
概念説明: ソフト削除はドキュメントを実際に削除するのではなく、isDeletedフラグを設定します。これにより、データを復旧可能にし、誤削除やコンプライアンス要件(GDPRなど)に対応できます。Mongooseミドルウェアで全クエリにisDeleted: false条件を自動付与できます。
const ProductSchema = new mongoose.Schema({
sku: { type: String, required: true, unique: true },
title: { type: String, required: true },
price: { type: Number, required: true },
isDeleted: { type: Boolean, default: false, select: false }
}, { timestamps: true });
// 全findクエリで自動フィルタリング(ソフト削除を除外)
ProductSchema.pre(/^find/, function(next) {
this.where({ isDeleted: { $ne: true } });
next();
});
// 復元メソッド
ProductSchema.methods.restore = function() {
this.isDeleted = false;
return this.save();
};
5. ミドルウェアとフック
概念説明: Mongooseミドルウェアはスキーマレベルの「ライフサイクルフック」です。pre(実行前)とpost(実行後)で、保存・更新・削除・クエリ操作の前後にカスタムロジックを挿入できます。典型的なユースケース:パスワードハッシュ化、データ正規化、監査ログ、関連データのカスケード更新。
ミドルウェアタイプ:
| タイプ | フック | 実行タイミング | ユースケース |
|---|---|---|---|
| ドキュメント | save, validate, remove |
モデルインスタンス操作前後 | パスワードハッシュ、データ正規化 |
| クエリ | find, update, delete |
クエリ実行前後 | ソフト削除フィルタ、監査ログ |
| 集計 | aggregate |
集計パイプライン実行前後 | ソフト削除フィルタ |
| モデル | insertMany, bulkWrite |
バルク操作前後 | バルクデータ検証 |
sequenceDiagram
participant App as アプリケーション
participant Pre as pre-save<br/>ミドルウェア
participant Model as Mongoose Model
participant DB as MongoDB
participant Post as post-save<br/>ミドルウェア
App->>Pre: user.save()
Pre->>Pre: パスワードをハッシュ化
Pre->>Model: next()
Model->>DB: 書き込み実行
DB-->>Model: 成功
Model->>Post: トリガー
Post->>Post: 監査ログを記録
Post-->>App: 返却
style Pre fill:#cce5ff
style Post fill:#d4edda
(1) パスワードハッシュ化(pre-save)
const bcrypt = require('bcrypt');
UserSchema.pre('save', async function(next) {
// passwordHashが変更された場合のみハッシュ化
if (!this.isModified('passwordHash')) return next();
// パスワードをハッシュ化
this.passwordHash = await bcrypt.hash(this.passwordHash, 10);
next();
});
// パスワード検証メソッド
UserSchema.methods.comparePassword = function(candidate) {
return bcrypt.compare(candidate, this.passwordHash);
};
(2) 更新タイムスタンプ(pre-update)
UserSchema.pre('findOneAndUpdate', function(next) {
// updatedAtを自動更新
this.set({ updatedAt: new Date() });
next();
});
(3) カスケード削除(post-delete)
UserSchema.post('findOneAndDelete', async function(doc) {
// ユーザー削除後、関連データをクリーンアップ
if (doc) {
await Review.deleteMany({ userId: doc._id });
await Cart.deleteOne({ userId: doc._id });
}
});
6. Expressルーティングとコントローラー
概念説明: ExpressルーティングはURLパスをコントローラー関数にマッピングします。コントローラーはリクエスト処理、データ検証、ビジネスロジック呼び出し、レスポンス返却を担当します。ルーティングをコントローラーから分離することで、URL構造の変更がビジネスロジックに影響せず、テストが容易になります。
RESTful API設計規約:
| 操作 | HTTPメソッド | パス | 説明 |
|---|---|---|---|
| 一覧 | GET | /api/products |
ページネーション付き商品一覧 |
| 詳細 | GET | /api/products/:id |
単一商品詳細 |
| 作成 | POST | /api/products |
新規商品作成 |
| 更新 | PUT | /api/products/:id |
商品全体更新 |
| 部分更新 | PATCH | /api/products/:id |
商品部分更新 |
| 削除 | DELETE | /api/products/:id |
商品削除 |
// === routes/products.js ===
const express = require('express');
const router = express.Router();
const productController = require('../controllers/productController');
const { authenticate, authorize } = require('../middlewares/auth');
const { validateProduct } = require('../middlewares/validate');
// 一般ユーザー向けルート
router.get('/', productController.listProducts);
router.get('/:id', productController.getProduct);
// 管理者専用ルート
router.post('/',
authenticate,
authorize('admin'),
validateProduct,
productController.createProduct
);
router.put('/:id',
authenticate,
authorize('admin'),
validateProduct,
productController.updateProduct
);
router.delete('/:id',
authenticate,
authorize('admin'),
productController.deleteProduct
);
module.exports = router;
// === controllers/productController.js ===
const Product = require('../models/Product');
exports.listProducts = async (req, res) => {
const { page = 1, limit = 20, category } = req.query;
const query = { isActive: true };
if (category) query.category = category;
const [products, total] = await Promise.all([
Product.find(query)
.select('sku title price thumbnail rating')
.sort({ createdAt: -1 })
.limit(+limit)
.skip((page - 1) * limit)
.lean(),
Product.countDocuments(query)
]);
res.json({
success: true,
data: products,
meta: { page: +page, limit: +limit, total }
});
};
exports.getProduct = async (req, res) => {
const product = await Product.findById(req.params.id);
if (!product) {
return res.status(404).json({ error: '商品が見つかりません' });
}
res.json(product);
};
exports.createProduct = async (req, res) => {
const product = await Product.create(req.body);
res.status(201).json(product);
};
exports.updateProduct = async (req, res) => {
const product = await Product.findByIdAndUpdate(
req.params.id,
req.body,
{ new: true, runValidators: true }
);
if (!product) {
return res.status(404).json({ error: '商品が見つかりません' });
}
res.json(product);
};
exports.deleteProduct = async (req, res) => {
const product = await Product.findByIdAndDelete(req.params.id);
if (!product) {
return res.status(404).json({ error: '商品が見つかりません' });
}
res.json({ message: '商品を削除しました' });
};
7. エラーハンドリング
概念概要: API開発では、エラーは「例外」ではなく「常態」です。適切なエラーハンドリング戦略は、ユーザーに有意義なフィードバックを提供し、開発者が問題を迅速に診断でき、システムが過度な情報を漏洩しないことを保証します。
エラーハンドリングのベストプラクティス:
| エラータイプ | HTTPステータス | ユーザーへのフィードバック | ログ記録 |
|---|---|---|---|
| 検証エラー | 400 | 「入力値が無効です」 | 警告レベル |
| 認証エラー | 401 | 「ログインしてください」 | 情報レベル |
| 認可エラー | 403 | 「権限がありません」 | 警告レベル |
| リソースなし | 404 | 「リソースが見つかりません」 | 情報レベル |
| 競合エラー | 409 | 「データが既に存在します」 | 情報レベル |
| サーバーエラー | 500 | 「サーバーエラーが発生しました」 | エラーレベル + スタックトレース |
// === middlewares/errorHandler.js ===
const errorHandler = (err, req, res, next) => {
console.error('エラー:', err);
// Mongoose検証エラー
if (err.name === 'ValidationError') {
const messages = Object.values(err.errors).map(e => e.message);
return res.status(400).json({
error: '検証エラー',
details: messages
});
}
// MongoDB重複キーエラー
if (err.code === 11000) {
const field = Object.keys(err.keyValue)[0];
return res.status(409).json({
error: `${field}は既に使用されています`
});
}
// JWTエラー
if (err.name === 'JsonWebTokenError') {
return res.status(401).json({ error: '無効なトークンです' });
}
// デフォルトサーバーエラー
res.status(500).json({
error: 'サーバーエラーが発生しました'
});
};
module.exports = errorHandler;
// === app.js ===
const errorHandler = require('./middlewares/errorHandler');
// ルーティングの後にエラーハンドラを登録
app.use(errorHandler);
8. 実践サンプル
▶ サンプル 1:Expressアプリケーション初期化(難易度 ⭐)
// app.js - Expressアプリケーションのエントリーポイント
const express = require('express');
const mongoose = require('mongoose');
const errorHandler = require('./middlewares/errorHandler');
const app = express();
// ミドルウェア設定
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// データベース接続
const connectDB = async () => {
try {
await mongoose.connect(process.env.MONGODB_URI, {
maxPoolSize: 50,
minPoolSize: 5,
serverSelectionTimeoutMS: 5000
});
console.log('MongoDB接続成功');
} catch (error) {
console.error('MongoDB接続エラー:', error.message);
process.exit(1);
}
};
// 接続イベント監視
mongoose.connection.on('disconnected', () => {
console.log('MongoDB接続が切断されました');
});
// ルーティング
app.use('/api/products', require('./routes/products'));
app.use('/api/users', require('./routes/users'));
// ヘルスチェック
app.get('/health', (req, res) => {
const dbStatus = mongoose.connection.readyState === 1 ? 'connected' : 'disconnected';
res.json({ status: 'ok', database: dbStatus });
});
// エラーハンドラ(最後に配置)
app.use(errorHandler);
// グレースフルシャットダウン
process.on('SIGINT', async () => {
await mongoose.connection.close();
console.log('MongoDB接続を閉じました');
process.exit(0);
});
// 起動
const PORT = process.env.PORT || 3000;
connectDB().then(() => {
app.listen(PORT, () => console.log(`サーバー起動: http://localhost:${PORT}`));
});
module.exports = app;
出力:
TEXT 📖 参照専用Expressアプリケーションの基本構成:Mongoose接続管理、ルーティング、エラーハンドリング、グレースフルシャットダウンを含む。
▶ サンプル 2:完全なCRUD API実装(難易度 ⭐⭐)
// controllers/productController.js - 商品CRUDコントローラー
const Product = require('../models/Product');
const mongoose = require('mongoose');
// 商品一覧(ページネーション + フィルタ)
exports.listProducts = async (req, res, next) => {
try {
const { page = 1, limit = 20, category, minPrice, maxPrice } = req.query;
const query = { isActive: true };
if (category) query.category = category;
if (minPrice || maxPrice) {
query.price = {};
if (minPrice) query.price.$gte = +minPrice;
if (maxPrice) query.price.$lte = +maxPrice;
}
const [products, total] = await Promise.all([
Product.find(query)
.select('sku title price category avgRating')
.sort({ createdAt: -1 })
.limit(+limit)
.skip((+page - 1) * +limit)
.lean(),
Product.countDocuments(query)
]);
res.json({
success: true,
data: products,
meta: { page: +page, limit: +limit, total, pages: Math.ceil(total / +limit) }
});
} catch (error) {
next(error);
}
};
// 商品詳細
exports.getProduct = async (req, res, next) => {
try {
const { id } = req.params;
if (!mongoose.Types.ObjectId.isValid(id)) {
return res.status(400).json({ success: false, error: '無効なID形式です' });
}
const product = await Product.findById(id);
if (!product || !product.isActive) {
return res.status(404).json({ success: false, error: '商品が見つかりません' });
}
res.json({ success: true, data: product });
} catch (error) {
next(error);
}
};
// 商品作成
exports.createProduct = async (req, res, next) => {
try {
const { sku, title, price, category, description, stock } = req.body;
// SKU重複チェック
const existing = await Product.findOne({ sku });
if (existing) {
return res.status(409).json({ success: false, error: `SKU ${sku}は既に存在します` });
}
const product = await Product.create({ sku, title, price, category, description, stock });
res.status(201).json({ success: true, data: product });
} catch (error) {
next(error);
}
};
// 商品更新
exports.updateProduct = async (req, res, next) => {
try {
const { id } = req.params;
const updates = req.body;
// 更新禁止フィールドを除外
delete updates._id;
delete updates.createdAt;
delete updates.__v;
const product = await Product.findByIdAndUpdate(
id,
{ $set: updates },
{ new: true, runValidators: true }
);
if (!product) {
return res.status(404).json({ success: false, error: '商品が見つかりません' });
}
res.json({ success: true, data: product });
} catch (error) {
next(error);
}
};
// 商品削除(ソフト削除)
exports.deleteProduct = async (req, res, next) => {
try {
const { id } = req.params;
const product = await Product.findByIdAndUpdate(
id,
{ $set: { isDeleted: true } },
{ new: true }
);
if (!product) {
return res.status(404).json({ success: false, error: '商品が見つかりません' });
}
res.status(204).send();
} catch (error) {
next(error);
}
};
出力:
TEXT 📖 参照専用商品CRUD API:一覧(ページネーション)、詳細、作成(重複チェック)、更新(禁止フィールド除外)、削除(ソフト削除)。
▶ サンプル 3:認証ミドルウェアとルーティング(難易度 ⭐⭐⭐)
// middlewares/auth.js - JWT認証ミドルウェア
const jwt = require('jsonwebtoken');
// JWT認証
exports.authenticate = (req, res, next) => {
const authHeader = req.header('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: '認証が必要です' });
}
const token = authHeader.replace('Bearer ', '');
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded; // { id, role, username }
next();
} catch (err) {
return res.status(401).json({ error: '無効なトークンです' });
}
};
// 権限チェック
exports.authorize = (...roles) => {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: '認証が必要です' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'この操作を行う権限がありません' });
}
next();
};
};
// routes/products.js - 商品ルーティング
const express = require('express');
const router = express.Router();
const productController = require('../controllers/productController');
const { authenticate, authorize } = require('../middlewares/auth');
// 公開ルート(認証不要)
router.get('/', productController.listProducts);
router.get('/:id', productController.getProduct);
// 管理者専用ルート
router.post('/',
authenticate,
authorize('admin'),
productController.createProduct
);
router.put('/:id',
authenticate,
authorize('admin'),
productController.updateProduct
);
router.delete('/:id',
authenticate,
authorize('admin'),
productController.deleteProduct
);
module.exports = router;
// テストコマンド
// 商品一覧: curl http://localhost:3000/api/products
// 商品作成(管理者): curl -X POST http://localhost:3000/api/products \
// -H "Authorization: Bearer <token>" \
// -H "Content-Type: application/json" \
// -d '{"sku":"TEST-001","title":"Test Product","price":99,"category":"Electronics"}'
出力:
TEXT 📖 参照専用JWT認証ミドルウェア(authenticate)と権限チェック(authorize)。商品APIは一般公開(一覧・詳細)と管理者専用(作成・更新・削除)に分離。
❓ よくある質問
config/db.jsにカプセル化し、アプリケーション起動時に一度だけ接続。select: falseの目的は?📖 まとめ
- プロジェクト構造:models / controllers / routes / middlewares分離
- 接続管理:接続プール + イベント監視 + グレースフルシャットダウン
- スキーマ設計:検証 + インデックス + ソフト削除
- ミドルウェア:pre/postフック + パスワードハッシュ + カスケード削除
- Expressルーティング:RESTful API規約 + エラーハンドリング
📝 練習問題
- 基礎問題(⭐): Mongoose接続管理モジュールを作成(接続プール設定 + イベント監視)。
- 基礎問題(⭐): Userスキーマを設計(メール検証 + パスワードハッシュ + タイムスタンプ)。
- 応用問題(⭐⭐): 商品CRUD APIを実装(ルーティング + コントローラー + エラーハンドリング)。
- 応用問題(⭐⭐): ソフト削除パターンを実装(pre-findフィルタ + 復元メソッド)。
- チャレンジ(⭐⭐⭐): 完全なExpressアプリケーションを構築(認証 + ルーティング + エラーハンドリング + 接続管理)。