oneie
Version:
Build apps, websites, and AI agents in English. Zero-interaction setup for AI agents (Claude Code, Cursor, Windsurf). Download to your computer, run in the cloud, deploy to the edge. Open source and free forever.
1,277 lines (1,038 loc) • 34.4 kB
Markdown
title: Frontend Effects
dimension: things
category: plans
tags: ai, backend, frontend, ui
related_dimensions: events, people
scope: global
created: 2025-11-03
updated: 2025-11-03
version: 1.0.0
ai_context: |
This document is part of the things dimension in the plans category.
Location: one/things/plans/frontend-effects.md
Purpose: Documents frontend effects.ts implementation plan
Related dimensions: events, people
For AI agents: Read this to understand frontend effects.
# Frontend Effects.ts Implementation Plan
**Comprehensive guide for building backend-agnostic frontend services with Effect.ts, React, and Astro**
**Version:** 1.0.0
**Status:** Planning Ready
**Stack:** Astro 5 + React 19 + Effect.ts + DataProvider Interface
**Target:** Type-safe, composable, testable frontend components that work with ANY backend
## Executive Summary
This document focuses exclusively on **frontend Effect.ts patterns** - how to structure the web layer (`/web/src/`) to be completely backend-agnostic while maintaining type safety, composability, and testability.
**Key Principle:** Frontend never knows about backend implementation. It only talks to DataProvider interface.
```
┌─────────────────────────────────────────────────┐
│ FRONTEND (Effect.ts Services) │
│ • Astro pages (SSR) │
│ • React components (islands) │
│ • Effect services (type-safe business logic) │
│ • React hooks (useEffectRunner, useService) │
└──────────────────┬──────────────────────────────┘
│
↓ DataProvider Interface
(Backend-agnostic contract)
```
## Architecture: Three Frontend Layers
### Layer 1: Presentation (Astro Pages + React Components)
**What:** UI rendering, user interaction, form handling
**Example:**
```tsx
// src/pages/courses/[courseId].astro
import CourseDetail from "@/components/courses/CourseDetail";
import { CourseService } from "@/services/CourseService";
const courseId = Astro.params.courseId;
// Fetch server-side
const course = await CourseService.get(courseId);
<Layout>
<CourseDetail client:load course={course} />
</Layout>
```
### Layer 2: Business Logic Services (Effect.ts)
**What:** Generic, backend-agnostic operations using DataProvider
**Example:**
```typescript
// src/services/CourseService.ts
import { Effect } from "effect";
import { DataProvider } from "@/providers/DataProvider";
export class CourseService extends Effect.Service<CourseService>()(...) {
effect: Effect.gen(function* () {
const provider = yield* DataProvider; // Backend-agnostic!
return {
get: (id) => provider.things.get(id),
list: (groupId) => provider.things.list({ type: "course", groupId })
};
})
}
```
### Layer 3: Data Access (DataProvider Interface)
**What:** Universal contract that ANY backend must implement
**Example:**
```typescript
// src/providers/DataProvider.ts
export interface DataProvider {
groups: { get; list; update };
people: { get; list; create };
things: { get; list; create; update; delete }; // Works with ANY backend
connections: { create; getRelated };
events: { log; query };
knowledge: { search };
}
```
## Part 1: Frontend Effect.ts Services (Cycle 51-60 from todo-effects)
### Service Definition Pattern
```typescript
// src/services/ThingService.ts
import { Effect, Context, Layer } from "effect";
import { DataProvider } from "@/providers/DataProvider";
// Define error types (tagged unions)
class ThingNotFoundError extends Data.TaggedError("ThingNotFoundError")<{
thingId: string;
}> {}
class ValidationError extends Data.TaggedError("ValidationError")<{
field: string;
message: string;
}> {}
// Define service interface
class ThingService extends Context.Tag("ThingService")<
ThingService,
{
readonly get: (id: string) => Effect.Effect<Thing, ThingNotFoundError>;
readonly list: (
type: ThingType,
groupId: string,
) => Effect.Effect<Thing[], Error>;
readonly create: (
input: CreateThingInput,
) => Effect.Effect<string, ValidationError>;
readonly update: (
id: string,
updates: Partial<Thing>,
) => Effect.Effect<void, Error>;
readonly delete: (id: string) => Effect.Effect<void, ThingNotFoundError>;
}
>() {}
// Implement with Effect
export const ThingServiceLive = Layer.effect(
ThingService,
Effect.gen(function* () {
const provider = yield* DataProvider; // Inject provider
return {
get: (id: string) =>
Effect.gen(function* () {
const thing = yield* provider.things.get(id);
return thing;
}),
list: (type: ThingType, groupId: string) =>
Effect.gen(function* () {
const things = yield* provider.things.list({
type,
organizationId: groupId,
});
return things;
}),
create: (input: CreateThingInput) =>
Effect.gen(function* () {
// Validate
const validated = yield* validateThingInput(input);
// Create via provider
const thingId = yield* provider.things.create({
type: input.type,
name: input.name,
organizationId: input.groupId,
properties: input.properties,
});
// Log event
yield* provider.events.log({
type: "thing_created",
actorId: input.actorId,
targetId: thingId,
organizationId: input.groupId,
});
return thingId;
}),
update: (id: string, updates: Partial<Thing>) =>
Effect.gen(function* () {
yield* provider.things.update(id, updates);
yield* provider.events.log({
type: "thing_updated",
targetId: id,
organizationId: updates.groupId,
});
}),
delete: (id: string) =>
Effect.gen(function* () {
yield* provider.things.delete(id);
yield* provider.events.log({
type: "thing_deleted",
targetId: id,
});
}),
};
}),
);
// Export service class
export { ThingService };
```
### Complete Services (Cycle 51-60)
```typescript
// src/services/index.ts
export { ThingService, ThingServiceLive } from "./ThingService";
export { ConnectionService, ConnectionServiceLive } from "./ConnectionService";
export { EventService, EventServiceLive } from "./EventService";
export { KnowledgeService, KnowledgeServiceLive } from "./KnowledgeService";
export { GroupService, GroupServiceLive } from "./GroupService";
export { PeopleService, PeopleServiceLive } from "./PeopleService";
```
## Part 2: React Hooks Integration (Cycle 52-60)
### useEffectRunner Hook (Core Pattern)
**Purpose:** Run Effect programs inside React components
```typescript
// src/hooks/useEffectRunner.ts
import { useState, useCallback } from "react";
import { Effect } from "effect";
import { ClientLayer } from "@/services/ClientLayer";
interface UseEffectRunnerOptions<A> {
onSuccess?: (result: A) => void;
onError?: (error: unknown) => void;
onFinally?: () => void;
}
export function useEffectRunner() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<unknown>(null);
const run = useCallback(
async <A, E>(
effect: Effect.Effect<A, E>,
options?: UseEffectRunnerOptions<A>,
): Promise<A | null> => {
setLoading(true);
setError(null);
try {
// Run effect with client layer (services + provider)
const result = await Effect.runPromise(
effect.pipe(Effect.provide(ClientLayer)),
);
options?.onSuccess?.(result);
return result;
} catch (err) {
setError(err);
options?.onError?.(err);
return null;
} finally {
setLoading(false);
options?.onFinally?.();
}
},
[],
);
return { run, loading, error };
}
```
### useThingService Hook (Generic CRUD)
```typescript
// src/hooks/useThingService.ts
import { useState, useEffect } from "react";
import { Effect } from "effect";
import { ThingService } from "@/services/ThingService";
import { useEffectRunner } from "./useEffectRunner";
export function useThingService(type: ThingType, groupId: string) {
const [things, setThings] = useState<Thing[]>([]);
const { run, loading } = useEffectRunner();
// Load things on mount
useEffect(() => {
const program = Effect.gen(function* () {
const service = yield* ThingService;
return yield* service.list(type, groupId);
});
run(program, { onSuccess: setThings });
}, [type, groupId]);
// Create thing
const create = (input: CreateThingInput) =>
Effect.gen(function* () {
const service = yield* ThingService;
const id = yield* service.create(input);
// Refresh list
const updated = yield* service.list(type, groupId);
setThings(updated);
return id;
});
// Update thing
const update = (id: string, updates: Partial<Thing>) =>
Effect.gen(function* () {
const service = yield* ThingService;
yield* service.update(id, updates);
// Refresh list
const updated = yield* service.list(type, groupId);
setThings(updated);
});
return { things, loading, create, update };
}
```
### useService Hook (Generic Service Access)
```typescript
// src/hooks/useService.ts
import { useCallback } from "react";
import { Effect } from "effect";
import { ClientLayer } from "@/services/ClientLayer";
/**
* Run any Effect service in React
*
* Usage:
* const thingService = useService(ThingService);
* const course = thingService.get(courseId);
*/
export function useService<S extends Effect.Service<any, any>>(service: S) {
return useCallback(
(operation: (svc: S) => Effect.Effect<any, any>) => {
const program = Effect.gen(function* () {
const svc = yield* service;
return yield* operation(svc);
});
return Effect.runPromise(program.pipe(Effect.provide(ClientLayer)));
},
[service],
);
}
```
## Part 3: Service Layer Composition (Cycle 53-60)
### ClientLayer (Dependency Injection)
```typescript
// src/services/ClientLayer.ts
import { Layer } from "effect";
import { ThingServiceLive } from "./ThingService";
import { ConnectionServiceLive } from "./ConnectionService";
import { EventServiceLive } from "./EventService";
import { KnowledgeServiceLive } from "./KnowledgeService";
import { GroupServiceLive } from "./GroupService";
import { PeopleServiceLive } from "./PeopleService";
import { convexProvider } from "@/providers/convex";
// Combine all services with provider
export const ClientLayer = Layer.mergeAll(
convexProvider({ url: import.meta.env.PUBLIC_CONVEX_URL }),
ThingServiceLive,
ConnectionServiceLive,
EventServiceLive,
KnowledgeServiceLive,
GroupServiceLive,
PeopleServiceLive,
);
```
### Service Composition Example
```typescript
// src/services/CourseService.ts - Domain-specific service
import { Effect, Context, Layer } from "effect";
import { ThingService } from "./ThingService";
import { ConnectionService } from "./ConnectionService";
class CourseService extends Context.Tag("CourseService")<
CourseService,
{
readonly createWithInstructor: (
courseData: CreateCourseInput,
instructorId: string,
groupId: string,
) => Effect.Effect<string, Error>;
}
>() {}
export const CourseServiceLive = Layer.effect(
CourseService,
Effect.gen(function* () {
const thingService = yield* ThingService; // Dependency!
const connectionService = yield* ConnectionService;
return {
createWithInstructor: (courseData, instructorId, groupId) =>
Effect.gen(function* () {
// 1. Create course (thing)
const courseId = yield* thingService.create({
type: "course",
name: courseData.name,
groupId,
properties: courseData.properties,
});
// 2. Link instructor (connection)
yield* connectionService.create({
fromPersonId: instructorId,
toThingId: courseId,
relationshipType: "teaches",
groupId,
});
return courseId;
}),
};
}),
);
```
## Part 4: Astro Integration (Cycle 41-43)
### Server-Side Data Fetching
```astro
// src/pages/courses/index.astro
import Layout from "@/layouts/Layout.astro";
import CourseList from "@/components/CourseList";
import { ThingService } from "@/services/ThingService";
import { ClientLayer } from "@/services/ClientLayer";
import { Effect } from "effect";
// Server-side: Fetch data with Effect
const groupId = Astro.locals.user?.groupId;
if (!groupId) {
return Astro.redirect("/login");
}
// Run Effect program on server
const courses = await Effect.runPromise(
Effect.gen(function* () {
const service = yield* ThingService;
return yield* service.list("course", groupId);
}).pipe(Effect.provide(ClientLayer))
);
<Layout>
<!-- Pass data to client component -->
<CourseList client:load initialCourses={courses} groupId={groupId} />
</Layout>
```
### Content Layout Wrapper (Astro Collections)
```astro
// src/layouts/BlogPost.astro
import BaseLayout from "./BaseLayout.astro";
import { KnowledgeService } from "@/services/KnowledgeService";
import { Effect } from "effect";
import { ClientLayer } from "@/services/ClientLayer";
interface Props {
frontmatter: any;
}
const { frontmatter } = Astro.props;
// Enrich content with dynamic data
const relatedArticles = await Effect.runPromise(
Effect.gen(function* () {
const knowledgeService = yield* KnowledgeService;
return yield* knowledgeService.search(frontmatter.title);
}).pipe(Effect.provide(ClientLayer))
);
<BaseLayout title={frontmatter.title}>
<article>
<h1>{frontmatter.title}</h1>
<slot /> <!-- Markdown content -->
{relatedArticles.length > 0 && (
<aside>
<h3>Related Articles</h3>
<ul>
{relatedArticles.map((article) => (
<li><a href={article.url}>{article.title}</a></li>
))}
</ul>
</aside>
)}
</article>
</BaseLayout>
```
## Part 5: Frontend Components Architecture (Cycle 54-60)
### Component Integration Layer
```tsx
// src/context/EffectContext.tsx
import { createContext, useContext, ReactNode } from "react";
import { Layer } from "effect";
import { ClientLayer } from "@/services/ClientLayer";
interface EffectProviderProps {
layer?: Layer<any>;
children: ReactNode;
}
const EffectLayerContext = createContext<Layer<any>>(ClientLayer);
export function EffectProvider({
layer = ClientLayer,
children,
}: EffectProviderProps) {
return (
<EffectLayerContext.Provider value={layer}>
{children}
</EffectLayerContext.Provider>
);
}
export function useEffectLayer() {
return useContext(EffectLayerContext);
}
```
### Interactive Component Example
```tsx
// src/components/CreateCourseForm.tsx
import { useState } from "react";
import { useEffectRunner } from "@/hooks/useEffectRunner";
import { ThingService } from "@/services/ThingService";
import { Effect } from "effect";
interface CreateCourseFormProps {
groupId: string;
onSuccess?: (courseId: string) => void;
}
export function CreateCourseForm({
groupId,
onSuccess,
}: CreateCourseFormProps) {
const [formData, setFormData] = useState({
name: "",
description: "",
price: 0,
});
const { run, loading, error } = useEffectRunner();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Define Effect program
const program = Effect.gen(function* () {
const thingService = yield* ThingService;
const courseId = yield* thingService.create({
type: "course",
name: formData.name,
groupId,
properties: {
description: formData.description,
price: formData.price,
},
actorId: "current-user-id", // From context
});
return courseId;
});
// Run program
const courseId = await run(program);
if (courseId) {
setFormData({ name: "", description: "", price: 0 });
onSuccess?.(courseId);
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Course name"
required
/>
<textarea
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
placeholder="Description"
/>
<input
type="number"
value={formData.price}
onChange={(e) =>
setFormData({ ...formData, price: parseFloat(e.target.value) })
}
placeholder="Price"
/>
<button type="submit" disabled={loading}>
{loading ? "Creating..." : "Create Course"}
</button>
{error && <div className="error">{String(error)}</div>}
</form>
);
}
```
## Part 6: Error Handling (Frontend-Specific)
### Domain Error Types
```typescript
// src/lib/errors.ts
import { Data } from "effect";
// Thing errors
class ThingNotFoundError extends Data.TaggedError("ThingNotFoundError")<{
thingId: string;
}> {}
class InvalidThingTypeError extends Data.TaggedError("InvalidThingTypeError")<{
type: string;
}> {}
class ThingCreationError extends Data.TaggedError("ThingCreationError")<{
reason: string;
}> {}
// Connection errors
class ConnectionAlreadyExistsError extends Data.TaggedError(
"ConnectionAlreadyExistsError",
)<{
fromId: string;
toId: string;
}> {}
// Validation errors
class ValidationError extends Data.TaggedError("ValidationError")<{
field: string;
message: string;
}> {}
// Infrastructure errors (from provider)
class ProviderError extends Data.TaggedError("ProviderError")<{
provider: string;
message: string;
}> {}
// Network errors
class NetworkError extends Data.TaggedError("NetworkError")<{
statusCode: number;
message: string;
}> {}
export type FrontendError =
| ThingNotFoundError
| InvalidThingTypeError
| ThingCreationError
| ConnectionAlreadyExistsError
| ValidationError
| ProviderError
| NetworkError;
// Human-friendly error messages
export function getErrorMessage(error: unknown): string {
if (error instanceof ThingNotFoundError) {
return `Course not found (${error.thingId})`;
}
if (error instanceof ValidationError) {
return `${error.field}: ${error.message}`;
}
if (error instanceof NetworkError) {
return `Connection failed (${error.statusCode})`;
}
return "Something went wrong";
}
```
### Error Handling in Components
```tsx
// src/hooks/useThingWithErrorHandling.ts
import { useState } from "react";
import { Effect } from "effect";
import { ThingService } from "@/services/ThingService";
import { useEffectRunner } from "./useEffectRunner";
export function useThingWithErrorHandling(thingId: string) {
const [thing, setThing] = useState<Thing | null>(null);
const { run, error, loading } = useEffectRunner();
const loadThing = () => {
const program = Effect.gen(function* () {
const service = yield* ThingService;
return yield* service.get(thingId).pipe(
Effect.catchTag(
"ThingNotFoundError",
(error) => Effect.succeed(null as Thing | null), // Graceful fallback
),
Effect.catchAll((error) => {
console.error("Error loading thing:", error);
return Effect.succeed(null);
}),
);
});
run(program, { onSuccess: setThing });
};
return { thing, loading, error, loadThing };
}
```
## Part 7: State Management with Effect (Cycle 55-57)
### Form State with Effect Validation
```typescript
// src/hooks/useForm.ts
import { useState, useCallback } from "react";
import { Effect, Schema } from "effect";
interface UseFormOptions<T> {
schema: Schema.Schema<T>;
onSubmit: (data: T) => Promise<void>;
}
export function useForm<T>(options: UseFormOptions<T>) {
const [formData, setFormData] = useState<Partial<T>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const validate = useCallback(
async (data: Partial<T>): Promise<T | null> => {
const program = Effect.gen(function* () {
try {
return yield* Schema.decodeUnknown(options.schema)(data);
} catch (error) {
// Extract field errors
const fieldErrors: Record<string, string> = {};
// Parse effect validation errors...
setErrors(fieldErrors);
return null;
}
});
return await Effect.runPromise(program);
},
[options.schema],
);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
const validated = await validate(formData);
if (validated) {
try {
await options.onSubmit(validated);
} catch (error) {
setErrors({ submit: String(error) });
}
}
setIsSubmitting(false);
},
[formData, validate, options],
);
return {
formData,
setFormData,
errors,
isSubmitting,
handleSubmit,
};
}
```
### Optimistic Updates Pattern
```tsx
// src/hooks/useOptimisticUpdate.ts
import { useState, useCallback } from "react";
import { Effect } from "effect";
import { useEffectRunner } from "./useEffectRunner";
interface UseOptimisticUpdateOptions<T> {
initial: T;
onSuccess?: (data: T) => void;
onError?: (error: unknown, rollback: T) => void;
}
export function useOptimisticUpdate<T>(options: UseOptimisticUpdateOptions<T>) {
const [data, setData] = useState(options.initial);
const [isUpdating, setIsUpdating] = useState(false);
const { run } = useEffectRunner();
const update = useCallback(
async (effect: Effect.Effect<T, any>, optimisticData: T) => {
const rollback = data;
// Optimistic update
setData(optimisticData);
setIsUpdating(true);
const result = await run(effect);
if (result) {
setData(result);
options.onSuccess?.(result);
} else {
// Rollback on error
setData(rollback);
options.onError?.(new Error("Update failed"), rollback);
}
setIsUpdating(false);
},
[data, run, options],
);
return { data, isUpdating, update };
}
```
## Part 8: Testing Frontend Services (Cycle 58-60)
### Mock Services for Testing
```typescript
// src/services/__mocks__/ThingService.ts
import { Layer } from "effect";
import { ThingService } from "../ThingService";
export const MockThingService = Layer.succeed(ThingService, {
get: (id) =>
Effect.succeed({
_id: id,
type: "course",
name: "Test Course",
properties: {},
status: "active",
createdAt: Date.now(),
updatedAt: Date.now(),
}),
list: (type, groupId) =>
Effect.succeed([
{
_id: "test-1",
type,
name: "Test Course 1",
properties: {},
status: "active",
createdAt: Date.now(),
updatedAt: Date.now(),
},
]),
create: (input) => Effect.succeed("new-id-123"),
update: (id, updates) => Effect.unit,
delete: (id) => Effect.unit,
});
```
### Component Testing with React Testing Library
```typescript
// src/components/__tests__/CreateCourseForm.test.tsx
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { CreateCourseForm } from "../CreateCourseForm";
import { EffectProvider } from "@/context/EffectContext";
import { Layer } from "effect";
import { MockThingService } from "@/services/__mocks__/ThingService";
describe("CreateCourseForm", () => {
it("should create course on submit", async () => {
const onSuccess = vi.fn();
const mockLayer = Layer.mergeAll(MockThingService);
render(
<EffectProvider layer={mockLayer}>
<CreateCourseForm groupId="test-group" onSuccess={onSuccess} />
</EffectProvider>
);
// Fill form
fireEvent.change(screen.getByPlaceholderText("Course name"), {
target: { value: "New Course" },
});
fireEvent.change(screen.getByPlaceholderText("Description"), {
target: { value: "Test description" },
});
fireEvent.change(screen.getByPlaceholderText("Price"), {
target: { value: "99" },
});
// Submit
fireEvent.click(screen.getByText("Create Course"));
// Verify success callback
await waitFor(() => {
expect(onSuccess).toHaveBeenCalledWith("new-id-123");
});
});
it("should display error on failure", async () => {
// Use error mock layer...
// Verify error is displayed
});
});
```
## Part 9: Type Safety and Validation (Cycle 59-60)
### Frontend Type Safety
```typescript
// src/types/domain.ts
import { Schema } from "effect";
// Define schemas with Effect
export const ThingSchema = Schema.Struct({
_id: Schema.String,
type: Schema.String,
name: Schema.String,
properties: Schema.Record(Schema.String, Schema.Unknown),
status: Schema.Literal("draft", "active", "archived"),
createdAt: Schema.Number,
updatedAt: Schema.Number,
});
export type Thing = Schema.Schema.Type<typeof ThingSchema>;
export const CreateThingInputSchema = Schema.Struct({
type: Schema.String,
name: Schema.String,
groupId: Schema.String,
properties: Schema.Record(Schema.String, Schema.Unknown),
});
export type CreateThingInput = Schema.Schema.Type<
typeof CreateThingInputSchema
>;
// Validation helper
export const validateThing = (data: unknown) =>
Schema.decodeUnknown(ThingSchema)(data);
export const validateCreateThingInput = (data: unknown) =>
Schema.decodeUnknown(CreateThingInputSchema)(data);
```
### Runtime Type Checking
```typescript
// src/hooks/useValidatedData.ts
import { useState, useEffect } from "react";
import { Schema } from "effect";
export function useValidatedData<T>(data: unknown, schema: Schema.Schema<T>) {
const [validated, setValidated] = useState<T | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
useEffect(() => {
Schema.decodeUnknown(schema)(data)
.then((result) => {
setValidated(result);
setValidationError(null);
})
.catch((error) => {
setValidated(null);
setValidationError(String(error));
});
}, [data, schema]);
return { validated, validationError };
}
```
## Part 10: Performance Optimization (Cycle 56-57)
### Memoization with Effect
```typescript
// src/hooks/useMemoEffect.ts
import { useMemo } from "react";
import { Effect } from "effect";
import { ClientLayer } from "@/services/ClientLayer";
export function useMemoEffect<A, E>(effect: Effect.Effect<A, E>, deps: any[]) {
return useMemo(
() => Effect.runPromise(effect.pipe(Effect.provide(ClientLayer))),
deps,
);
}
```
### Lazy Loading Components
```tsx
// src/components/LazyThingList.tsx
import { Suspense, lazy } from "react";
const ThingList = lazy(() => import("./ThingList"));
export function LazyThingListWrapper({ groupId }: { groupId: string }) {
return (
<Suspense fallback={<div>Loading...</div>}>
<ThingList groupId={groupId} />
</Suspense>
);
}
```
### Request Deduplication
```typescript
// src/lib/requestCache.ts
import { Effect } from "effect";
class RequestCache {
private cache = new Map<string, Promise<any>>();
get<A>(key: string, effect: Effect.Effect<A, any>): Promise<A> {
if (this.cache.has(key)) {
return this.cache.get(key)!;
}
const promise = Effect.runPromise(effect);
this.cache.set(key, promise);
// Clear cache after 30 seconds
setTimeout(() => this.cache.delete(key), 30000);
return promise;
}
}
export const requestCache = new RequestCache();
```
## Directory Structure
```
web/
├── src/
│ ├── pages/ # Astro pages (SSR)
│ │ ├── index.astro
│ │ ├── courses/
│ │ │ ├── index.astro
│ │ │ └── [courseId].astro
│ │ └── blog/
│ │ └── [...slug].astro
│ │
│ ├── components/ # React components (islands)
│ │ ├── CreateCourseForm.tsx
│ │ ├── CourseDetail.tsx
│ │ ├── ThingList.tsx
│ │ └── __tests__/
│ │ └── CreateCourseForm.test.tsx
│ │
│ ├── services/ # Effect.ts services
│ │ ├── index.ts
│ │ ├── ClientLayer.ts # DI composition
│ │ ├── ThingService.ts # Generic CRUD
│ │ ├── ConnectionService.ts # Relationships
│ │ ├── EventService.ts # Audit trail
│ │ ├── KnowledgeService.ts # Search
│ │ ├── GroupService.ts
│ │ ├── PeopleService.ts
│ │ ├── CourseService.ts # Domain-specific
│ │ └── __mocks__/
│ │ └── ThingService.ts
│ │
│ ├── providers/ # DataProvider implementations
│ │ ├── DataProvider.ts # Interface
│ │ ├── convex/
│ │ │ └── ConvexProvider.ts
│ │ ├── wordpress/
│ │ │ └── WordPressProvider.ts
│ │ └── supabase/
│ │ └── SupabaseProvider.ts
│ │
│ ├── hooks/ # React hooks
│ │ ├── useEffectRunner.ts # Core
│ │ ├── useThingService.ts # CRUD
│ │ ├── useService.ts # Generic
│ │ ├── useForm.ts # Forms
│ │ ├── useOptimisticUpdate.ts # Optimism
│ │ └── useMemoEffect.ts # Caching
│ │
│ ├── context/ # React context
│ │ ├── EffectContext.tsx # Effect layer
│ │ ├── UserContext.tsx # Current user
│ │ └── GroupContext.tsx # Current group
│ │
│ ├── lib/ # Utilities
│ │ ├── errors.ts # Error types
│ │ ├── requestCache.ts # Caching
│ │ └── validation.ts # Schema validation
│ │
│ ├── types/ # TypeScript types
│ │ ├── domain.ts # Domain types with schemas
│ │ └── index.ts
│ │
│ └── layouts/ # Astro layouts
│ ├── Layout.astro
│ └── BlogPost.astro
│
├── astro.config.ts # Provider config
├── tsconfig.json
├── package.json
└── test/ # E2E tests
├── auth.test.ts
└── courses.test.ts
```
## Migration Path: From Current Convex to Backend-Agnostic
### Phase 1: Create DataProvider Interface (3 days)
- Define interface in `/web/src/providers/DataProvider.ts`
- No component changes needed
- Just type definitions
### Phase 2: Implement ConvexProvider (3-5 days)
- Wrap existing Convex client
- Implement all DataProvider methods
- Keep existing Convex integration working
### Phase 3: Build Effect.ts Services (3-5 days)
- Create `ThingService`, `ConnectionService`, etc.
- Services delegate to DataProvider
- No component changes yet
### Phase 4: Create React Hooks (2-3 days)
- Implement `useEffectRunner`, `useThingService`, etc.
- Provide as alternatives to Convex hooks
- Both can coexist
### Phase 5: Migrate Components (2-4 weeks)
- Page by page, replace Convex hooks with Effect services
- Each component still works during migration
- Minimal risk - can rollback any page
### Phase 6: Remove Convex Dependencies (1-2 days)
- Remove `convex` from `package.json`
- Delete `/web/convex/` directory
- Frontend is now backend-agnostic!
## Key Principles
✅ **Backend-Agnostic:** Frontend never imports backend-specific code
✅ **Type-Safe:** Full TypeScript + Effect.ts error channels
✅ **Composable:** Services combine via Layer.mergeAll
✅ **Testable:** Mock layers, not databases
✅ **Simple:** DataProvider interface is small, learnable contract
✅ **Gradual:** Migrate page by page, no big-bang rewrite
✅ **Zero-Downtime:** Existing system keeps working during migration
## Summary
This document provides a complete roadmap for building a **frontend service layer with Effect.ts** that is:
1. **Completely decoupled from backend** - uses DataProvider interface
2. **Type-safe** - full TypeScript + Effect error tracking
3. **Composable** - services build on each other
4. **Testable** - mock layers for unit tests
5. **Production-ready** - patterns from 14.ai + effect.ts community
The architecture enables:
- **Organizations to choose their backend** (Convex, WordPress, Supabase, Notion, etc.)
- **Developers to work without backend knowledge** (just learn DataProvider interface)
- **Gradual migration** from direct Convex integration
- **Multi-backend support** (route different data types to different backends)
- **Zero-downtime deployment** (pages migrate independently)
## Next Steps
1. **Read:** `/one/connections/effect-patterns.md` (Effect.ts fundamentals)
2. **Review:** `/one/knowledge/ontology.md` (6-dimension data model)
3. **Implement:** Phase 1-2 (DataProvider + ConvexProvider)
4. **Test:** Verify existing auth tests still pass
5. **Scale:** Add services, migrate components gradually
**Goal:** Complete backend-agnostic frontend in 4-6 weeks with zero downtime.
**Built with type safety, clarity, and infinite flexibility in mind.**
_— ONE Platform Engineering Team_