🤘

GraphQL スキーマ設計の知見:実戦から学んだ推奨パターンとアンチパターン

に公開

こんにちは。
株式会社ココナラ 募集部所属の開発エンジニア みん です。

2025年5月にココナラで新しい スカウト機能 を公開した後、バックエンドの GraphQL スキーマに対してリファクタリングを実施しました。

この記事の位置づけ: 実装の詳細ではなく、GraphQL スキーマ設計の思想と原則に焦点を当てた内容です。実践経験から学んだ設計パターンとアンチパターンを整理し、長期的に保守しやすいスキーマを構築するための指針を提供します。

はじめに:なぜスキーマ設計がこんなに重要なのか?

GraphQL の世界では、スキーマこそが API 契約です。単なる型宣言ではなく、フロントエンドとバックエンド、およびサービス間の「共通言語」です。

GraphQL スキーマは強い型付け自己記述的であり、一度本番環境にデプロイすると、スキーマの変更はすべてのユーザーに影響します。そのため:

  • 一度の設計が長期的に影響を与える:スキーマの品質が開発体験とメンテナンスコストに直結
  • 後方互換性に注意が必要:悪い設計は最終的に Breaking Changes になる
  • ビジネスセマンティクス > データベース構造:スキーマはビジネスロジックを抽象化すべき

この記事では、命名 → 型 → 入力 → 責任 → 構造 → 性能の順で、実戦から学んだ推奨パターンとアンチパターンを整理します。

コア思想:client-first のスキーマ設計

使いやすい GraphQL スキーマの本質は:

「クライアントのユースケースを中心とした API モデル」であって、
「データベースフィールドの GraphQL 版」ではない。

いくつかの重要なコンセプト:

  • Usage-driven(ユースケース駆動):画面、実際のクエリ、フロントエンドのニーズから逆算してスキーマを設計する
  • 複数の方法を提供することを恐れない:汎用的な単一エンドポイントより、明確で意味のあるクエリ/ミューテーションを提供する方が健全
  • スキーマ自体が「自己説明的」であるべき:フィールドの説明(description)は加点要素だが、ドキュメントなしでもフィールドの用途を理解できるのがベスト
  • バックエンドで業務ロジックを完結させる:価格計算、ステータス判定、権限チェックなどはバックエンドで処理し、フロントエンドには「そのまま使える」データを提供する
    • ただし、表示ラベル・色・アイコンなどの UI 都合の要素や、複数クライアントで仕様が割れるケースは BFF 層で扱うのも有効な選択肢

主題 1:命名設計 - ビジネスセマンティクスを優先する

なぜ重要か?

スキーマの命名は API の「顔」です。データベースの内部構造を露出するのではなく、ビジネスの言葉で語ることが、長期的なメンテナンス性と開発者体験の鍵となります。

説明例:EC サイトの商品管理

以下は一般的な EC サイトを例に、命名設計の重要性を説明します。商品管理一覧・詳細表示、在庫管理、ステータス表示などの機能を想定したシナリオです。

❌ データベース構造をそのまま露出した設計

ProductMst { product_id, product_nm, status_flag, del_flg }

🔴 問題点: DB の都合が API に露出し、型安全性もない

✅ 改善後の設計:ビジネスセマンティクス優先

@ObjectType({ description: 'EC サイトの商品情報' })
export class Product {
  @Field(() => String, { description: '商品 ID(ULID)' })
  productId: string;  // 外部公開用 ID

  @Field(() => String)
  name: string;

  @Field(() => ProductStatus)  // Enum で型安全に
  status: ProductStatus;

  @Field(() => String, { description: '価格の表示テキスト(例:¥1,234)' })
  formattedPrice: string;  // バックエンドで計算済み

  @Field(() => Boolean)
  isVisible: boolean;  // del_flg をビジネス意味に変換
}

enum ProductStatus {
  AVAILABLE = 'AVAILABLE',
  OUT_OF_STOCK = 'OUT_OF_STOCK',
  DISCONTINUED = 'DISCONTINUED',
}

改善ポイント対比表

項目 ❌ 問題のある設計 ✅ 改善された設計
型名 ProductMst Product
ID product_id: Int productId: String (ULID)
命名規則 product_nm, stock_qty name, stockQuantity (camelCase)
ステータス status_flag: String status: ProductStatus (Enum)
削除フラグ del_flg 露出 isVisible (ビジネス意味)

この改善によるメリット

  • DB と API の分離 - DB リファクタリングが API に影響しなくなる
  • 型安全性の向上 - Enum により有限状態を明示し、エディタの補完が効くようになる
  • セキュリティの強化 - 内部 DB ID は ULID/UUID を使用し、実装詳細を露出しない

この章で持ち帰る判断基準:「DB の都合を隠蔽すべき」

主題 2:型システム - 型の力を最大限活用する

なぜ重要か?

正しい型設計は、バグを実行前に発見できる最強の防御線です。GraphQL の型システムを最大限活用することで、フォーマットエラーを防ぎ、IDE の補完を効かせ、フロントエンドとバックエンドの間で型安全な通信を実現できます。

特に日付・時刻・有限状態などの「制約のあるデータ」は、String で曖昧に定義するのではなく、Custom Scalar や Enum を使って明確に型付けすることが重要です。

説明例:レストラン予約システム

以下はレストラン予約システムを例に、型システムの活用方法を説明します。日付・時刻のフォーマット不統一や不正な値、タイムゾーンの扱いなど、よくある問題を想定したシナリオです。

❌ すべて String 型の設計

@ObjectType()
export class BookingModel {
  @Field(() => String)
  bookingDate: string;  // "2024-12-08"? "12/08/2024"? "2024-13-45"?

  @Field(() => String)
  startTime: string;    // "14:30"? "14:30:00"? "25:70"?

  @Field(() => String)
  status: string;       // "pending"? "PENDING"? "confirmed"?
}

✅ 改善後の設計 - Custom Scalar + Enum + Description

@ObjectType({ description: 'レストラン予約情報' })
export class BookingModel {
  @Field(() => LocalDate, {
    description: '予約日(YYYY-MM-DD 形式)例: 2024-12-08',
  })
  bookingDate: LocalDateString;

  @Field(() => LocalTime, {
    description: '開始時刻(HH:mm:ss 形式)例: 14:30:00',
  })
  startTime: string;

  @Field(() => BookingStatus, {
    description: '予約ステータス(PENDING: 確認待ち, CONFIRMED: 確認済み)',
  })
  status: BookingStatus;
}

// Custom Scalar の概念
// LocalDate は parse 時にフォーマット(/^\d{4}-\d{2}-\d{2}$/)と日付合法性を検証
// → 不正な入力は Resolver に到達する前にブロック

改善ポイント対比表

項目 ❌ 問題のある設計 ✅ 改善された設計
日付型 String (フォーマット不明) LocalDate Scalar (YYYY-MM-DD 強制)
時刻型 String (フォーマット不明) LocalTime Scalar (HH:mm:ss 強制)
ステータス String (取りうる値不明) BookingStatus Enum (PENDING, CONFIRMED 等)
バリデーション 各 Resolver に分散 Scalar 内で一元化

この改善によるメリット

  1. コンパイル時 + 実行時の二重検証 - Custom Scalar が不正なフォーマットを実行時にブロックし、Codegen が生成した型で TypeScript がコンパイル時にもチェックする
  2. フォーマットエラーの早期発見 - 不正なリクエストは Resolver に到達する前に自動的にブロックされる
  3. IDE の補完が効く - Enum を使うことで、IDE が取りうる値を自動補完してくれる
  4. バリデーションロジックの一元化 - 日付フォーマットのチェックが Scalar 内の 1箇所に集約される
  5. 保守性の大幅な向上 - 新しいフィールド追加時も Scalar を再利用できる

この章で持ち帰る判断基準:「制約がある値は適切な型で表現すべき」

主題 3:入力検証とエラーハンドリング - セキュリティの第一防線

なぜ重要か?

入力検証は、システムセキュリティの第一防線です。適切な検証がないと、不正なデータがデータベースに保存され、後続の処理でエラーが発生したり、セキュリティの脆弱性が生まれたりします。また、エラーをスキーマの一部として明示的に設計することで、フロントエンドが予測可能で安全なエラーハンドリングを実装できます。

説明例:SNS の投稿作成機能

以下は SNS プラットフォームを例に、入力検証の重要性を説明します。投稿作成機能(タイトル 5-100 文字、本文最大 5000 文字、タグ最大 5 個)を想定したシナリオです。

❌ すべて nullable + 検証なしの設計

CreatePostInput { title?: String, content?: String }
→ Service 層で if (!title || title.length < 5) throw Error()

🔴 問題点: 検証ロジックが分散し、Schema で制約が表現されていない

✅ 改善後の設計:Input 検証 + Union Result

import { IsNotEmpty, Length, MaxLength } from 'class-validator';

@InputType()
export class CreatePostInput {
  @Field(() => String, { description: '投稿タイトル(5-100文字)' })
  @IsNotEmpty({ message: 'タイトルは必須です' })
  @Length(5, 100, {
    message: 'タイトルは5文字以上、100文字以内で入力してください'
  })
  title: string;

  @Field(() => String, { description: '投稿本文(最大5000文字)' })
  @MaxLength(5000)
  content: string;

  @Field(() => PostVisibility)
  @IsEnum(PostVisibility)
  visibility: PostVisibility;
}

// Union Result Types
@ObjectType()
export class PostCreated {
  @Field(() => Post)
  post: Post;
  @Field(() => String)
  message: string;
}

@ObjectType()
export class ValidationFailed {
  @Field(() => [ValidationError])
  errors: ValidationError[];
}

export const CreatePostResult = createUnionType({
  name: 'CreatePostResult',
  types: () => [PostCreated, ValidationFailed, RateLimitError] as const,
});

// Resolver
@Mutation(() => CreatePostResult)
async createPost(@Args('input') input: CreatePostInput): Promise<typeof CreatePostResult> {
  const isAllowed = await this.rateLimiter.checkLimit(ctx.userId, 'create_post');
  if (!isAllowed) {
    return { retryAfter: 3600, message: '投稿の作成回数が上限に達しました' };
  }
  const post = await this.postService.create(input, ctx.userId);
  return { post, message: '投稿を作成しました' };
}

改善ポイント対比表

項目 ❌ 問題のある設計 ✅ 改善された設計
Input 検証 なし(Service 層で手動チェック) class-validator decorators
エラーハンドリング throw Error / null / undefined Union Result(型安全)
nullable 使用 すべて nullable 本当にオプショナルなフィールドのみ
検証ロジック Service 層に分散 Input DTO に集約

この改善によるメリット

  1. Input 層での不正データ遮断 - class-validator により GraphQL 層で自動検証し、Service 層到達前に弾くことができる
  2. 型安全なエラーハンドリング - Union Result + __typename + TypeScript により型安全性が保証される
  3. 検証ロジックの一元化 - Input DTO に集約されるため、変更は1箇所のみで済む
  4. 自動ドキュメント生成 - description と validator が GraphQL スキーマに自動的に反映される

この章で持ち帰る判断基準:「入力と予期エラーは Schema で型として表現すべき」

主題 4:責任分離と業務ロジック - BFF(backend for frontend)の実践

なぜ重要か?

「GraphQL は単なるデータ取得 API ではなく、フロントエンドの開発体験を最大化するインターフェース」という考え方が重要です。

利用側が "Grab and Use" (取得してそのまま使える)設計を実現することで、複雑な計算や判断ロジックをフロントエンドに分散させず、バックエンドで完結させることができます。

説明例:EC サイトの注文管理システム

以下は EC サイトを例に、責任分離の設計パターンを説明します。注文管理における価格計算、権限管理、ステータス表示、注文更新などの機能を想定したシナリオです。

❌ フロントエンドが責任過多な設計

// Backend: 生データのみ返す
@ObjectType()
export class Order {
  @Field(() => Float)
  basePrice: number;

  @Field(() => Float)
  taxRate: number;

  @Field(() => String)
  status: string;  // "pending", "confirmed"...

  @Field(() => String)
  userId: string;
}

// Frontend: 計算・判断ロジックが分散
const totalPrice = order.basePrice * (1 + order.taxRate);
const canEdit = order.status === 'pending' && order.userId === currentUser.id;
const statusLabel = order.status === 'pending' ? '確認待ち' : '確認済み';

✅ 改善後の設計

1. 価格計算 → PriceBreakdown 型

@ObjectType()
export class PriceBreakdown {
  @Field(() => Float)
  basePrice: number;

  @Field(() => Float)
  tax: number;

  @Field(() => Float)
  finalPrice: number;  // バックエンドで計算済み

  @Field(() => String)
  formattedPrice: string;  // "¥1,234" バックエンドでフォーマット済み
}

@ObjectType()
export class Order {
  @Field(() => PriceBreakdown)
  pricing: PriceBreakdown;
}

2. 権限判断 → OrderPermissions 型

@ObjectType()
export class OrderPermissions {
  @Field(() => Boolean)
  canEdit: boolean;  // バックエンドで判断済み

  @Field(() => Boolean)
  canCancel: boolean;

  @Field(() => Boolean)
  canView: boolean;
}

@ObjectType()
export class Order {
  @Field(() => OrderPermissions)
  permissions: OrderPermissions;
}

3. ステータス表示 → OrderStatusDisplay 型

@ObjectType()
export class OrderStatusDisplay {
  @Field(() => OrderStatus)
  status: OrderStatus;

  @Field(() => String)
  label: string;  // "確認待ち"

  @Field(() => String)
  color: string;  // "#FFA500"

  @Field(() => String)
  icon: string;   // "pending_icon"
}

@ObjectType()
export class Order {
  @Field(() => OrderStatusDisplay)
  statusDisplay: OrderStatusDisplay;
}

改善ポイント対比表

項目 ❌ 問題のある設計(Frontend が計算) ✅ 改善された設計(Backend が提供)
価格計算 basePrice * (1 + taxRate) pricing.finalPrice
価格フォーマット formatCurrency(price) pricing.formattedPrice
権限判断 status === 'pending' && userId === me permissions.canEdit
ステータス表示 status === 'pending' ? '確認待ち' : '確認済み' statusDisplay.label
色判断 getStatusColor(status) statusDisplay.color

💡 ドメインとプレゼン層の境界について

フィールドをどこで提供するか迷ったときの判断基準:

  • formattedPriceBackend が適切

    • 多プラットフォームで共通の価値が高い(iOS/Android/Web で同じフォーマット)
    • 通貨記号、桁区切りなどビジネスルールとして統一すべき
  • color, iconBFF/表示層が適切なケースも

    • UI 差分が出やすい(iOS は SF Symbols、Android は Material Icons など)
    • クライアントごとのデザインシステムに依存する場合は分離を検討

原則として、複数クライアントで共通のビジネス価値があるフィールドは Backendプラットフォーム固有の表現要素は BFF/表示層と覚えておくと良いでしょう。

この改善によるメリット

  1. フロントエンドの単純化 - 計算・判断ロジックが不要になり、データをそのまま表示できる
  2. ビジネスロジックの一元化 - 価格計算、権限判断がバックエンドで統一される
  3. マルチプラットフォーム対応 - iOS/Android/Web で同じロジックを使用でき、一貫性が保たれる
  4. テストの容易性 - バックエンドで包括的にテストできる

この章で持ち帰る判断基準:「ビジネスロジックはバックエンドで完結すべき」

主題 5:API アーキテクチャ設計 - スケーラブルな設計

なぜ重要か?

プロジェクトが成長するにつれ、「Query が多すぎて、どれを使えばいいかわからない」「類似した Query が乱立」という問題が発生します。Resource-based の階層構造とユースケース駆動の設計により、拡張性と発見性を両立できます。

説明例:ブログプラットフォーム

以下はブログプラットフォームを例に、スケーラブルな API 設計を説明します。記事管理・下書き・公開機能において、ユーザー視点(自分の記事)と管理者視点(全記事管理)で異なるクエリが必要になるシナリオです。

❌ フラット構造 + 万能 filter の設計

type Query {
  articles(filter: ArticleFilter): [Article!]!
  articleById(id: ID!): Article
  articlesByStatus(status: String): [Article!]!
  articlesByAuthor(authorId: ID!): [Article!]!
  draftArticles: [Article!]!
  publishedArticles: [Article!]!
  # ... 似たような Query が増殖
}

input ArticleFilter {
  status: String
  authorId: ID
  categoryId: ID
  keyword: String
  publishedAfter: DateTime
  publishedBefore: DateTime
  # ... フィールドが無限に増える
}

✅ 改善後の設計(Resource-based + ユースケース駆動)

type Query {
  # Resource-based 階層構造
  authorResource: AuthorResource!
  readerResource: ReaderResource!
  adminResource: AdminResource!
}

# 著者視点(記事を書く人)
type AuthorResource {
  myArticles: [Article!]!
  myDrafts: [Article!]!
  myPublishedArticles: [Article!]!
  myArticleById(id: ID!): Article
  myArticleStats: ArticleStats!
}

# 読者視点(記事を読む人)
type ReaderResource {
  feed: [Article!]!
  recommendedArticles: [Article!]!
  articlesByCategory(categoryId: ID!): [Article!]!
  searchArticles(keyword: String!): [Article!]!
}

# 管理者視点
type AdminResource {
  allArticles(status: ArticleStatus): [Article!]!
  pendingReviews: [Article!]!
  reportedArticles: [Article!]!
}

改善ポイント対比表

項目 ❌ 問題のある設計(フラット構造) ✅ 改善された設計(Resource-based)
構造 フラットな Query 一覧 Resource 単位で階層化
命名 統一性なし プレフィックス統一(my*, all*
フィルター 万能 ArticleFilter ユースケース駆動の Query
役割分離 混在 authorResource / readerResource / adminResource で明確化
発見性 困難(100+ Query から探す) 容易(Resource → Query)

この改善によるメリット

  1. 明確なドメイン分離 - Resource 単位で責任範囲が明確になる
  2. Query の発見性向上 - フラット構造では 100+ Query から探す必要があるが、Resource-based では 3階層で到達できる
  3. 段階的な拡張の容易性 - 新 Resource を追加しても既存 API に影響を与えない
  4. 命名の一貫性 - プレフィックス統一により、パターンが明確になる

⚠️ 使いどころの注意

Resource-based 構造は強力ですが、すべてのプロジェクトで最初から必要というわけではありません:

  • 小規模・初期段階: まずフラット構造で始める(10-20 Query 程度)
  • 成長の兆し: 30-50 Query を超えたあたりで Resource 化を検討(経験則)
    • ⚠️ この数字は「シグナル」であって硬規則ではありません。プロジェクトの複雑性やチーム規模で判断してください
  • 大規模・複数チーム: Resource-based で責任範囲を明確化

プロジェクトの成長に合わせて、段階的に Resource 化していくのが現実的なアプローチです。

この章で持ち帰る判断基準:「Query が増えたら Resource 化を検討すべき」

主題 6:性能最適化 - スケールする設計

なぜ重要か?

「動作する」と「スケールする」は全く別物です。開発初期は数百ユーザー、数千投稿で問題なく動いていても、ユーザー数が 10 倍、100 倍になると、突然システムが破綻します。

GraphQL は柔軟性が高い分、N+1 クエリ問題過剰なデータ取得などの性能問題が発生しやすい特性があります。

説明例:SNS のタイムライン表示

以下は SNS のタイムライン機能を例に、性能最適化の戦略を説明します。投稿情報・ユーザー情報・統計情報の表示、無限スクロール対応が必要なシナリオです。

❌ 性能問題多発の設計

query Timeline {
  feed(limit: 20) {
    id
    author { name }      # ← 20 posts = 20 queries (N+1)
    comments { text }    # ← nested N+1
    likesCount           # ← 20 COUNT queries
  }
}

🔴 どう爆発するか:

  • 20 投稿 → author + comments + count で 200+ クエリ
  • ピーク時にタイムアウト多発
  • offset ページネーションで大量メモリ消費

✅ 改善後の設計(3つの最適化戦略)

戦略 1: DataLoader で N+1 問題を解決

GraphQL が N+1 問題を引き起こしやすい理由

GraphQL のリゾルバーは独立して実行されるため、投稿リスト → 各投稿の著者 → 各投稿のコメントというクエリは、バックエンドで 1 + 20 + 20 = 41 回のデータベースクエリが発生する可能性があります。

DataLoader の核心メカニズム

  1. バッチング - 複数の個別クエリを1つにまとめる(findById(1,2,3)findByIds([1,2,3])
  2. キャッシング - 同一リクエスト内で結果を再利用

いつ DataLoader を使うべきか

  • ✅ リレーションシップフィールド(author, category, comments
  • ✅ ネストしたリスト
  • ✅ 集計フィールド(likesCount, commentsCount

クエリ複雑度の監視

GraphQL は柔軟性が高いため、クライアントが意図せず高コストなクエリを送信する可能性があります。対策として:

  • クエリ深度制限 - ネストレベルを制限(例:最大5階層)
  • コスト計算 - フィールドごとにコストを設定し、合計コストで制限
  • DataLoader の適用 - すべてのリレーションシップに適用

戦略 2: Relay Connection でページネーション

Relay Connection とは

Facebook が策定した GraphQL のページネーション仕様です。単なるリスト返却ではなく、メタデータ(edges, pageInfo, totalCount)を含む構造化されたレスポンスを提供します。

Cursor-based vs Offset-based

Offset-based の問題点:

  • offset: 1000 を指定すると、データベースは最初の 1000 件をスキャンして捨てる
  • データが追加・削除されると、ページ重複・欠落が発生
  • 大きな offset では性能が著しく劣化

Cursor-based の利点:

  • データベースは WHERE id > 123 のような効率的なクエリを実行(インデックス活用)
  • リアルタイムでデータが変化しても安定
  • 無限スクロールに最適

いつ使うべきか

  • ✅ 無限スクロール(SNS のタイムライン、記事一覧)
  • ✅ 大量データ(数千件以上)
  • ✅ リアルタイム性が重要(チャット、通知)
  • ⚠️ ページ番号指定が必須の場合(管理画面など)は offset-based も検討

戦略 3: 集計フィールドの事前計算

統計情報は事前計算してキャッシュまたは DB に保存します。

結果: リアルタイム集計(遅い)→ 事前計算(高速)

これらの改善によるメリット

  1. N+1 問題の解決 - DataLoader により大幅にクエリ数を削減できる
  2. レスポンス時間の短縮 - Cursor-based ページネーションと事前計算により高速化される
  3. データベース負荷の軽減 - バッチクエリと事前計算により DB への負荷が軽減される
  4. スケーラビリティの向上 - 10倍、100倍のトラフィックにも耐えられる設計になる

この章で持ち帰る判断基準:「動作することとスケールすることは別物」

おわりに:6つの主題で実現するスケーラブルな GraphQL 設計

この記事では、6つの主題を通じて GraphQL スキーマ設計のベストプラクティスを学びました:

  1. 命名設計 - ビジネスセマンティクスを優先
  2. 型システム - 型の力を最大限活用
  3. 入力検証とエラーハンドリング - 安全の第一防線
  4. 責任分離と業務ロジック - BFFの実践
  5. API アーキテクチャ設計 - スケーラブルな設計
  6. 性能最適化 - スケールする設計

5つの設計原則

  1. ビジネス中心設計 - スキーマはビジネスの抽象化、技術的な実装詳細を隠蔽
  2. 型安全性 - 強力な型システムを活用、コンパイル時 + 実行時の検証
  3. 責任の明確化 - バックエンド: ビジネスロジック、フロントエンド: データ表示
  4. スケーラビリティ - 拡張可能な構造、性能を考慮した設計
  5. 開発者体験 - 統一された命名規則、自己文書化、使いやすい API

実践へのロードマップ

Step 1: 評価と優先順位付け

  • 6つの主題に照らして現状を分析(命名・型・入力・責任・構造・性能)
  • 性能問題とセキュリティ問題を最優先で特定
  • 影響度と緊急度でロードマップを作成

Step 2: 段階的な改善と移行

  • 一度にすべて変更せず、@deprecated で旧 API を残しながら移行
  • Union Result 化は段階的に(既存クライアントが errors[] 前提の場合は慎重に)
  • Resource 化は 30-50 Query を超えたあたりで検討

Step 3: チーム浸透と継続改善

  • 設計原則をドキュメント化し、コードレビューで品質を維持
  • 新機能は常にベストプラクティスに従う
  • 定期的に設計をレビューし、アーキテクチャを進化させる

設計時のチェックポイント

次に Query / Mutation を追加する前に、ちょっと立ち止まって考えてみてください:

  • この設計は「それを使うフロントエンド」にとって理解しやすいか?
  • ビジネスロジックはバックエンドで完結しているか?
  • 型安全にエラーハンドリングできているか?
  • 性能問題(N+1、ページネーション)を考慮しているか?
  • 10倍、100倍のスケールに耐えられるか?

GraphQL の真の力は、適切な設計によって発揮されます。


募集部では、全員がフルスタックエンジニアとして活躍し、プロダクトの企画・設計から実装まで一気通貫で携わっています。エンジニアがプロダクトの方向性にも積極的に意見を出せる環境です。

技術とプロダクトづくりの両方に関心がある方は、ぜひカジュアル面談にご応募ください!

採用情報はこちら
https://coconala.co.jp/recruit/engineer/

また、カジュアルにココナラの魅力やキャリアパスについて詳しく聞いてみませんか?

カジュアル面談をご希望の方はこちら
https://open.talentio.com/r/1/c/coconala/pages/70417

Discussion