1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Flutter×Express×TypeScriptで簡単なCrudサンプルを作る

1
Last updated at Posted at 2026-06-19

ログイン機能をもったTODOのCRUDサンプルをFlutter×Express.js(ts)で作成したい。
セッションはExpress-sessionを使用すること。
DBはPostgresです。
DB情報は.envファイルに書き込みその情報を読み取ってDB接続をして生のSQLでCRUDします。
PrismaなどのORマッパーは使いません。
Express.js(ts)のアーキテクチャはオニオンアーキテクチャです。

要件

■ Flutter
■ Express.js + TypeScript
■ オニオンアーキテクチャ
■ express-session
■ PostgreSQL
■ .envからDB接続情報取得
■ pgライブラリ使用
■ ORM未使用(生SQL)
■ ログイン機能
■ TODO CRUD
■ Session認証

ディレクトリ構成

Backend

backend/
├── src/
│   ├── domain/
│   │   ├── entities/
│   │   │   ├── User.ts
│   │   │   └── Todo.ts
│   │   └── repositories/
│   │       ├── IUserRepository.ts
│   │       └── ITodoRepository.ts
│   │
│   ├── application/
│   │   ├── services/
│   │   │   ├── AuthService.ts
│   │   │   └── TodoService.ts
│   │
│   ├── infrastructure/
│   │   ├── db/
│   │   │   └── postgres.ts
│   │   └── repositories/
│   │       ├── UserRepository.ts
│   │       └── TodoRepository.ts
│   │
│   ├── presentation/
│   │   ├── controllers/
│   │   │   ├── AuthController.ts
│   │   │   └── TodoController.ts
│   │   └── routes/
│   │       ├── authRoutes.ts
│   │       └── todoRoutes.ts
│   │
│   ├── middleware/
│   │   └── authMiddleware.ts
│   │
│   ├── types/
│   │   └── session.d.ts
│   │
│   └── app.ts
│
├── .env
├── package.json
├── tsconfig.json
└── database.sql

package.json

下記の通りインストールします。

npm install express express-session pg dotenv cors bcrypt

npm install -D typescript      
npm install -D ts-node-dev
npm install -D @types/express
npm install -D @types/express-session
npm install -D @types/node
npm install -D @types/cors
npm install -D @types/bcrypt
npm i @types/pg
npm i @types/express-session
npm i @types/express

.env

.env
PORT=3000

DB_HOST=localhost
DB_PORT=5432
DB_NAME=todo_db
DB_USER=postgres
DB_PASSWORD=password

SESSION_SECRET=my-secret-key

PostgreSQL

database.sql

database.sql
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) UNIQUE NOT NULL,
    password VARCHAR(255) NOT NULL
);

CREATE TABLE todos (
    id SERIAL PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    completed BOOLEAN DEFAULT FALSE,
    user_id INTEGER NOT NULL REFERENCES users(id)
);

Domain

User.ts

User.ts
export interface User {
  id: number;
  email: string;
  password: string;
}

Todo.ts

Todo.ts
export interface Todo {
  id: number;
  title: string;
  completed: boolean;
  user_id: number;
}

Repository Interface

IUserRepository.ts

IUserRepository.ts
import { User } from "../entities/User";

export interface IUserRepository {
  findByEmail(email: string): Promise<User | null>;
  create(email: string, password: string): Promise<User>;
}

ITodoRepository.ts

ITodoRepository.ts
import { Todo } from "../entities/Todo";

export interface ITodoRepository {
  findAll(userId: number): Promise<Todo[]>;
  create(title: string, userId: number): Promise<Todo>;
  update(
    id: number,
    title: string,
    completed: boolean
  ): Promise<void>;
  delete(id: number): Promise<void>;
}

PostgreSQL接続

postgres.ts

postgres.ts
import { Pool } from "pg";
import dotenv from "dotenv";

dotenv.config();

export const pool = new Pool({
  host: process.env.DB_HOST,
  port: Number(process.env.DB_PORT),
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD
});

Infrastructure

UserRepository.ts

UserRepository.ts
import { pool } from "../db/postgres";
import { IUserRepository } from "../../domain/repositories/IUserRepository";
import { User } from "../../domain/entities/User";

export class UserRepository implements IUserRepository {

  async findByEmail(email: string): Promise<User | null> {

    const result = await pool.query(
      `SELECT * FROM users WHERE email = $1`,
      [email]
    );

    return result.rows[0] || null;
  }

  async create(email: string, password: string): Promise<User> {

    const result = await pool.query(
      `
      INSERT INTO users(email,password)
      VALUES($1,$2)
      RETURNING *
      `,
      [email,password]
    );

    return result.rows[0];
  }
}

TodoRepository.ts

TodoRepository.ts
import { pool } from "../db/postgres";

export class TodoRepository {

  async findAll(userId:number) {

    const result = await pool.query(
      `
      SELECT *
      FROM todos
      WHERE user_id=$1
      ORDER BY id
      `,
      [userId]
    );

    return result.rows;
  }

  async create(title:string,userId:number) {

    const result = await pool.query(
      `
      INSERT INTO todos(title,user_id)
      VALUES($1,$2)
      RETURNING *
      `,
      [title,userId]
    );

    return result.rows[0];
  }

  async update(
    id:number,
    title:string,
    completed:boolean
  ) {

    await pool.query(
      `
      UPDATE todos
      SET title=$1,
          completed=$2
      WHERE id=$3
      `,
      [title,completed,id]
    );
  }

  async delete(id:number) {

    await pool.query(
      `
      DELETE FROM todos
      WHERE id=$1
      `,
      [id]
    );
  }
}

Application Layer

AuthService.ts

AuthService.ts
import bcrypt from "bcrypt";
import { UserRepository } from "../../infrastructure/repositories/UserRepository";

export class AuthService {

  constructor(
    private userRepo: UserRepository
  ) {}

  async register(
    email:string,
    password:string
  ) {

    const hash = await bcrypt.hash(password,10);

    return await this.userRepo.create(
      email,
      hash
    );
  }

  async login(
    email:string,
    password:string
  ) {

    const user =
      await this.userRepo.findByEmail(email);

    if(!user) return null;

    const ok =
      await bcrypt.compare(
        password,
        user.password
      );

    return ok ? user : null;
  }
}

TodoService.ts

TodoService.ts
import { TodoRepository } from "../../infrastructure/repositories/TodoRepository";

export class TodoService {

  constructor(
    private repo: TodoRepository
  ) {}

  getTodos(userId:number) {
    return this.repo.findAll(userId);
  }

  createTodo(
    title:string,
    userId:number
  ) {
    return this.repo.create(title,userId);
  }

  updateTodo(
    id:number,
    title:string,
    completed:boolean
  ) {
    return this.repo.update(
      id,
      title,
      completed
    );
  }

  deleteTodo(id:number) {
    return this.repo.delete(id);
  }
}

Session型拡張

session.d.ts

session.d.ts
import "express-session";

declare module "express-session" {

  interface SessionData {
    userId?: number;
  }

}

Middleware

authMiddleware.ts

authMiddleware.ts
import { Request, Response, NextFunction } from "express";

export const authMiddleware = (
  req: Request,
  res: Response,
  next: NextFunction
) => {

  if (!req.session.userId) {
    return res.status(401).json({
      message: "Unauthorized"
    });
  }

  next();
};

Controller

AuthController.ts

AuthController.ts
import { Request, Response } from "express";

export class AuthController {

  constructor(
    private authService:any
  ) {}

  register = async (
    req:Request,
    res:Response
  ) => {

    const {email,password}=req.body;

    const user =
      await this.authService.register(
        email,
        password
      );

    res.json(user);
  };

  login = async (
    req:Request,
    res:Response
  ) => {

    const {email,password}=req.body;

    const user =
      await this.authService.login(
        email,
        password
      );

    if(!user){
      return res.status(401).json({
        message:"login failed"
      });
    }

    req.session.userId = user.id;

    res.json({
      message:"login success"
    });
  };

  logout = (
    req:Request,
    res:Response
  ) => {

    req.session.destroy(() => {
      res.json({message:"logout"});
    });

  };
}

TodoController.ts

TodoController.ts
import { Request, Response } from "express";

export class TodoController {

  constructor(
    private service:any
  ) {}

  getAll = async (
    req:Request,
    res:Response
  ) => {

    const todos =
      await this.service.getTodos(
        req.session.userId
      );

    res.json(todos);
  };

  create = async (
    req:Request,
    res:Response
  ) => {

    const todo =
      await this.service.createTodo(
        req.body.title,
        req.session.userId
      );

    res.json(todo);
  };

  update = async (
    req:Request,
    res:Response
  ) => {

    await this.service.updateTodo(
      Number(req.params.id),
      req.body.title,
      req.body.completed
    );

    res.json({message:"updated"});
  };

  delete = async (
    req:Request,
    res:Response
  ) => {

    await this.service.deleteTodo(
      Number(req.params.id)
    );

    res.json({message:"deleted"});
  };
}

Routes

authRoutes.ts

authRoutes.ts
import { Router } from "express";

const router = Router();

router.post("/register", controller.register);
router.post("/login", controller.login);
router.post("/logout", controller.logout);

export default router;

todoRoutes.ts

todoRoutes.ts
import { Router } from "express";

const router = Router();

router.get("/", controller.getAll);
router.post("/", controller.create);
router.put("/:id", controller.update);
router.delete("/:id", controller.delete);

export default router;

app.ts

app.ts
import express from "express";
import session from "express-session";
import dotenv from "dotenv";
import cors from "cors";

dotenv.config();

const app = express();

app.use(express.json());

app.use(cors({
  origin:"http://localhost:3001",
  credentials:true
}));

app.use(
  session({
    secret:
      process.env.SESSION_SECRET!,
    resave:false,
    saveUninitialized:false,
    cookie:{
      secure:false,
      httpOnly:true
    }
  })
);

app.use("/auth", authRoutes);

app.use(
  "/todos",
  authMiddleware,
  todoRoutes
);

app.listen(
  process.env.PORT,
  () => {
    console.log("server start");
  }
);

Flutter側

依存

yaml.yaml
dependencies:
  flutter:
    sdk: flutter

  dio: ^5.4.0

APIクライアント

import 'package:dio/dio.dart';

class ApiClient {

  final dio = Dio(
    BaseOptions(
      baseUrl: "http://localhost:3000",
    ),
  );

}

Login

await dio.post(
  "/auth/login",
  data: {
    "email": email,
    "password": password
  },
  options: Options(
    extra: {
      "withCredentials": true
    }
  )
);

Todo取得

final res =
    await dio.get("/todos");

final todos =
    List<Map<String,dynamic>>
        .from(res.data);

API一覧

POST   /auth/register
POST   /auth/login
POST   /auth/logout

GET    /todos
POST   /todos
PUT    /todos/:id
DELETE /todos/:id

Flutterディレクトリ

Flutter側もオニオンアーキテクチャ(または Clean Architecture)に寄せるなら、以下の構成がおすすめです。

flutter_todo/
├── lib/
│
│   ├── core/
│   │   ├── constants/
│   │   │   └── api_constants.dart
│   │   │
│   │   ├── network/
│   │   │   ├── dio_client.dart
│   │   │   └── cookie_interceptor.dart
│   │   │
│   │   ├── error/
│   │   │   ├── app_exception.dart
│   │   │   └── failure.dart
│   │   │
│   │   └── utils/
│   │       └── validator.dart
│   │
│   ├── domain/
│   │   ├── entities/
│   │   │   ├── user.dart
│   │   │   └── todo.dart
│   │   │
│   │   ├── repositories/
│   │   │   ├── auth_repository.dart
│   │   │   └── todo_repository.dart
│   │   │
│   │   └── usecases/
│   │       ├── login_usecase.dart
│   │       ├── logout_usecase.dart
│   │       ├── get_todos_usecase.dart
│   │       ├── create_todo_usecase.dart
│   │       ├── update_todo_usecase.dart
│   │       └── delete_todo_usecase.dart
│   │
│   ├── infrastructure/
│   │   ├── models/
│   │   │   ├── login_request.dart
│   │   │   ├── todo_model.dart
│   │   │   └── user_model.dart
│   │   │
│   │   ├── datasources/
│   │   │   ├── auth_remote_datasource.dart
│   │   │   └── todo_remote_datasource.dart
│   │   │
│   │   └── repositories/
│   │       ├── auth_repository_impl.dart
│   │       └── todo_repository_impl.dart
│   │
│   ├── presentation/
│   │   ├── pages/
│   │   │   ├── login/
│   │   │   │   └── login_page.dart
│   │   │   │
│   │   │   └── todo/
│   │   │       └── todo_page.dart
│   │   │
│   │   ├── providers/
│   │   │   ├── auth_provider.dart
│   │   │   └── todo_provider.dart
│   │   │
│   │   └── widgets/
│   │       ├── todo_tile.dart
│   │       └── loading_widget.dart
│   │
│   ├── di/
│   │   └── dependency_injection.dart
│   │
│   └── main.dart
│
├── pubspec.yaml
└── .env

レイヤーごとの責務

Domain

業務ロジックのみ。

domain/
├── entities
├── repositories
└── usecases

class Todo {
  final int id;
  final String title;
  final bool completed;

  Todo({
    required this.id,
    required this.title,
    required this.completed,
  });
}

Repositoryはインターフェースのみ

abstract class TodoRepository {
  Future<List<Todo>> getTodos();
  Future<Todo> createTodo(String title);
  Future<void> updateTodo(
    int id,
    String title,
    bool completed,
  );
  Future<void> deleteTodo(int id);
}

Infrastructure

API通信担当。

infrastructure/
├── datasources
├── models
└── repositories

DataSource

class TodoRemoteDataSource {

  final Dio dio;

  TodoRemoteDataSource(this.dio);

  Future<List<dynamic>> getTodos() async {
    final response = await dio.get("/todos");
    return response.data;
  }
}

Repository実装

class TodoRepositoryImpl
    implements TodoRepository {

  final TodoRemoteDataSource datasource;

  TodoRepositoryImpl(this.datasource);

  @override
  Future<List<Todo>> getTodos() async {

    final data =
        await datasource.getTodos();

    return data
        .map((e) => TodoModel.fromJson(e))
        .toList();
  }
}

Presentation

画面専用。

presentation/
├── pages
├── providers
└── widgets

Provider

class TodoProvider
    extends ChangeNotifier {

  final GetTodosUseCase getTodosUseCase;

  List<Todo> todos = [];

  TodoProvider(
    this.getTodosUseCase,
  );

  Future<void> loadTodos() async {

    todos =
      await getTodosUseCase();

    notifyListeners();
  }
}

Session(Cookie)対応

Express-sessionを使う場合、

dependencies:
  dio: ^5.4.0
  dio_cookie_manager: ^3.1.1
  cookie_jar: ^4.0.8
final dio = Dio();

final cookieJar = CookieJar();

dio.interceptors.add(
  CookieManager(cookieJar),
);

これで

Set-Cookie

を自動保存できます。

ログイン後の

GET /todos

にSession Cookieが自動送信されます。

DI設定

例えば Provider を使う場合

final dio = Dio();

final authDatasource =
    AuthRemoteDataSource(dio);

final todoDatasource =
    TodoRemoteDataSource(dio);

final authRepository =
    AuthRepositoryImpl(
      authDatasource,
    );

final todoRepository =
    TodoRepositoryImpl(
      todoDatasource,
    );

final loginUseCase =
    LoginUseCase(
      authRepository,
    );

final getTodosUseCase =
    GetTodosUseCase(
      todoRepository,
    );

これを

lib/di/dependency_injection.dart

にまとめます。

おまけ

小規模TODOアプリなら

実務でよく使う構成は以下です。

lib/
├── core/
├── features/
│   ├── auth/
│   └── todo/
├── di/
└── main.dart

Feature First構成です。

features/
├── auth/
│   ├── domain/
│   ├── infrastructure/
│   └── presentation/
│
└── todo/
    ├── domain/
    ├── infrastructure/
    └── presentation/

Flutterではこちらの方が保守しやすく、Express側のオニオンアーキテクチャとも相性が良いです。
個人的には、ログイン+TODO程度なら Feature First + Clean Architecture の構成をおすすめします。

サイト

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?