Skip to content

RFC: Multi-package architecture - Core (FP) + Server + API #57

@AliiiBenn

Description

@AliiiBenn

Problem

Currently, @deessejs/functions is a single monorepo package that contains both:

  • Functional programming patterns: outcome, result, maybe, retry, sleep, error handling
  • API layer: query, mutation, context, router, hooks, lifecycle

This has two problems:

  1. Users who only need FP patterns must install the entire package
  2. There is no clear path for generating real HTTP APIs from function definitions

Solution: Multi-package architecture

Package 1: Core (FP patterns)

Package name: @deessejs/core

Contains:

  • outcome.ts - Success/failure types
  • result.ts - Result types
  • maybe.ts - Maybe/Option types
  • try.ts - Try/Catch handling
  • async-result.ts - Async result types
  • retry.ts - Retry utilities
  • sleep.ts - Delay utilities
  • Error types (Cause, Exception)

Use case: Anyone needing functional programming patterns without API concerns.

Package 2: Server (Definitions + Local)

Package name: @deessejs/server

Contains:

  • query() / mutation() - Function constructors
  • defineContext() / createAPI() - Context management
  • Router system
  • Hooks (beforeInvoke, onSuccess, onError)
  • Aliases
  • Cache invalidation stream
  • Local executor (in-process execution)
  • Plugin system - Plugins can register new queries/mutations

Use case: Server actions, lambdas, workers, any in-process function calls.

Package 3: HTTP API

Package name: @deessejs/api

Built on Hono under the hood for:

  • Lightweight and fast
  • Works with Next.js via hono/vercel
  • Universal: Cloudflare Workers, Deno, Bun, Node.js

Contains:

Use case: Real APIs for external clients (web, desktop, mobile).

The Vision: Dual Mode

Define functions once in @deessejs/server:

// server.ts
import { defineContext, query } from "@deessejs/server";

const { t, createAPI } = defineContext({ db });

const api = createAPI({
  router: t.router({
    users: t.router({
      get: t.query({
        args: z.object({ id: z.number() }),
        handler: async (ctx, args) => { ... }
      }),
      create: t.mutation({
        args: z.object({
          name: z.string(),
          email: z.string().email(),
        }),
        handler: async (ctx, args) => { ... }
      }),
    }),
  }),
  plugins: [authPlugin, paginationPlugin]
});

export { api };

Mode 1 - Local (Server Actions)

// app/actions.ts
import { api } from "./server";

// Direct, in-process, no network overhead
const user = await api.users.get({ id: 1 });

// Mutations work the same way
await api.users.create({ name: "John", email: "john@example.com" });

Mode 2 - HTTP (External API)

Next.js App Router

// app/api/deesse/[...slug]/route.ts
import { api } from "../../server"
import { createHandler } from "@deessejs/api"

export const GET = createHandler(api)
export const POST = createHandler(api)
export const DELETE = createHandler(api)
export const PATCH = createHandler(api)

Next.js Pages Router

// pages/api/deesse/[[...slug]].ts
import { api } from "../../server"
import { createHandler } from "@deessejs/api"

export default createHandler(api)

Standalone Server (Express/Fastify compatible)

// server.ts
import { api } from "./server"
import { createHonoApp } from "@deessejs/api"

const app = createHonoApp(api)
export default app

Cloudflare Workers

// src/index.ts
import { api } from "./server"
import { createHonoApp } from "@deessejs/api"

const app = createHonoApp(api)
export default app

Plugin System

Plugins can extend the API by adding new queries and mutations dynamically:

// plugins/auth.ts
import { Plugin } from "@deessejs/server"

export const authPlugin: Plugin = {
  name: "auth",

  queries: {
    getCurrentUser: t.query({
      args: {},
      handler: async (ctx) => {
        return success(ctx.user)
      }
    }),
  },

  mutations: {
    login: t.mutation({
      args: z.object({
        email: z.string().email(),
        password: z.string(),
      }),
      handler: async (ctx, args) => {
        const user = await ctx.db.users.findByEmail(args.email)
        return success({ token: "..." })
      }
    }),

    logout: t.mutation({
      args: {},
      handler: async (ctx) => {
        return success(true)
      }
    }),
  },

  onInvalidate: ["getCurrentUser"],
}

Using Plugins

// server.ts
import { defineContext, createAPI } from "@deessejs/server"
import { authPlugin } from "./plugins/auth"

const { t, createAPI } = defineContext({ db })

const api = createAPI({
  router: t.router({
    users: t.router({
      get: t.query({ ... }),
      create: t.mutation({ ... }),
    }),
  }),
  plugins: [authPlugin]
})

// Now available:
// api.users.get({ id: 1 })         // from router
// api.users.create({ ... })        // from router
// api.getCurrentUser()             // from authPlugin
// api.login({ email, password })  // from authPlugin
// api.logout()                    // from authPlugin

Static Generation (CLI)

For better performance, plugins can pre-generate their queries/mutations at build time:

# Generate static queries/mutations from plugins
npx deesse generate

This creates a generated folder with:

  • Type definitions for all plugin queries/mutations
  • Client SDK extensions
  • Route handlers

Client SDK Usage

After setting up the server, clients can use the auto-generated SDK:

// React/Next.js client
import { createClient } from "@deessejs/api/client"

const client = createClient("http://localhost:3000/api/deesse")

// Query - automatically generates cache key based on function name + args
const { data, error } = await client.users.get({ id: 1 })
if (data.ok) {
  console.log(data.value)
}

// Mutation - automatically invalidates related cache keys
const { data, error } = await client.users.create({
  name: "John",
  email: "john@example.com"
})

Or with React hooks (see #51 for automatic cache revalidation):

import { useQuery, useMutation } from "@deessejs/api/client"

function UserProfile({ id }) {
  // Cache key automatically generated: ["users.get", { id }]
  // When cache is invalidated (see #52), this automatically refetches
  const { data } = useQuery(client.users.get, { id })
  return <div>{data?.value.name}</div>
}

function CreateUser() {
  // Mutation automatically invalidates related cache keys
  const { mutate } = useMutation(client.users.create)
  return <button onClick={() => mutate({ name: "John", email: "john@example.com" })}>
    Create
  </button>
}

Cache key system:

  • Each query generates a unique cache key: ["functionName", args]
  • Each mutation automatically invalidates related cache keys
  • Smart cache-key registry (see Add smart cache-keys registry #52) allows custom invalidation patterns

HTTP API Endpoints

The auto-generated endpoints:

GET  /api/deesse/users.get?args={id:1}
POST /api/deesse/users.get { id: 1 }
POST /api/deesse/users.create { name: "John", email: "john@example.com" }

Summary

Package Role
@deessejs/core FP patterns (outcome, result, retry, maybe...)
@deessejs/server Define query/mutation + local execution + cache stream + plugins
@deessejs/api Auto-generated HTTP handlers + client SDK + cache integration

Single definition, Multiple execution modes, Plugin system, Automatic cache management

Questions

  1. Package names: @deessejs/core, @deessejs/server, @deessejs/api?
  2. Should HTTP API package live in the same repo?
  3. What other adapters could be useful (WebSocket, gRPC)?
  4. Timeline: start now or iterate on current architecture?

Related Issues

References

  • Inspired by Convex (query/mutation model)
  • DX inspired by Payload CMS (auto-generated route handlers)
  • Using Hono under the hood (works with Next.js, Vercel, Cloudflare, Deno, Bun)
  • Unlike Convex: built on functional programming patterns (outcome, result)
  • More flexible than tRPC (pure functions, no classes)

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions