AgentStack
Browse Sign in
Browse Why AgentStack Sell Docs
Sign in
SKILL verified MIT Self-run

Ioc Development Pattern

skill-ryanmakesandbreaksstuff-custom-codex-claude-plugins-and-skills-ioc-development-pattern · by RyanMakesAndBreaksStuff

Inversion of Control (IoC) development pattern for building apps with in-memory test data first, then swapping to real data sources. Covers service interfaces, domain models with DTOs, in-memory implementations, service registry, React Query (TanStack Query) integration with cache invalidation, error handling, and incremental backend migration. Triggers on "IoC", "inversion of control", "in-memor…

No reviews yet
0 installs
26 views
0.0% view→install

Install

$ agentstack add skill-ryanmakesandbreaksstuff-custom-codex-claude-plugins-and-skills-ioc-development-pattern

✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.

Security review

✓ Passed

No issues found. Passed automated security review. · v0.1.0 How review works →

  • Prompt-injection patterns
  • Secret / credential exfiltration
  • Dangerous shell & filesystem operations
  • Untrusted network calls
  • Known-malicious package signatures

What it can access

  • Network access No
  • Filesystem access No
  • Shell / process execution No
  • Environment & secrets No
  • Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

View the full security report →

Verified badge

Passed review? Show it. Paste this badge into your README, it links to the public security report.

AgentStack Verified badge Links to your public security report.
[![AgentStack Verified](https://agentstack.voostack.com/badges/verified.svg)](https://agentstack.voostack.com/security/report/skill-ryanmakesandbreaksstuff-custom-codex-claude-plugins-and-skills-ioc-development-pattern)

Reliability & compatibility

Security review passed
0 installs to date
no reviews yet
2mo ago

Declared compatibility

Claude CodeClaude Desktop

Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.

Preview Execution monitoring

We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.

How agent discovery & health will work →
Are you the author of Ioc Development Pattern? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

About

IoC Development Pattern

Build your entire UI against in-memory test data first. Connect to real data sources later.

This pattern applies to any Power Platform frontend — Code Apps (Dataverse, Azure SQL), Power Pages code sites (Dataverse Web API), or any app that consumes external data.

Why This Pattern

  • Fast local iteration — No network calls, no auth, no environment setup needed during UI development
  • Demo without infrastructure — Show working UI to stakeholders before any backend exists
  • Testable — In-memory implementations double as test fixtures
  • Clean separation — Components depend on interfaces, not backends. Swap data sources without touching UI code
  • Incremental migration — Connect one entity at a time; mix in-memory and real services during transition

Step 1: Define Service Interfaces and Domain Models

Create a TypeScript interface for each data entity your app needs. The interface describes what the app needs, not how any specific backend works.

Base Service Interface

// src/services/interfaces.ts

export interface IEntityService {
  getAll(options?: QueryOptions): Promise;
  getById(id: string): Promise;
  create(entity: Omit): Promise;
  update(id: string, changes: Partial): Promise;
  delete(id: string): Promise;
}

export interface QueryOptions {
  filter?: string;
  orderBy?: string;
  top?: number;
  select?: string[];
}

Domain Model Types

Each domain entity gets three type definitions:

  1. Domain model — what the app works with (full shape with proper types)
  2. CreateDto — fields for creating a new record (only user-controlled fields)
  3. UpdateDto — fields for updating (all optional, only include what changed)
  4. Filters — query parameters for list operations
// src/features/projects/models/Project.ts

/** Domain model — what the app works with */
export interface Project {
  id: string;
  name: string;
  description: string | null;
  status: "Active" | "Completed" | "On Hold";
  startDate: Date;
  budget: number;
  createdOn: Date;
  modifiedOn: Date;
}

/** Fields for creating a new record — only user-controlled fields */
export interface CreateProjectDto {
  name: string;
  description?: string;
  status?: Project["status"];
  startDate?: Date;
  budget?: number;
}

/** Fields for updating — all optional, only include what changed */
export interface UpdateProjectDto {
  name?: string;
  description?: string;
  status?: Project["status"];
  startDate?: Date;
  budget?: number;
}

/** Query filters */
export interface ProjectFilters {
  status?: Project["status"];
  search?: string;
}

Entity Service Interface

// src/services/interfaces.ts (continued)

export interface IProjectService extends IEntityService {
  getByStatus(status: Project["status"]): Promise;
}

export interface ITaskService extends IEntityService {
  getByProject(projectId: string): Promise;
}

DTO Design Principles

  • CreateDto contains ONLY fields the user controls — never IDs, ownership, state codes, or timestamps
  • UpdateDto has all fields optional — only include what changed
  • Domain model has proper types (Date not string, boolean not number, enums not raw strings)
  • Design the interface around your app's needs, not the backend's shape. The implementation handles translation between your domain types and the backend's raw types

Pagination Interface (Design from Day 1)

Include pagination in your service interface from the start — don't bolt it on later:

interface PageOptions {
  page: number;
  pageSize: number;
  sortField?: string;
  sortDirection?: 'asc' | 'desc';
  search?: string;
  filter?: Record;
}

interface PagedResult {
  items: T[];
  totalCount: number;
  page: number;
  pageSize: number;
}

interface IEntityService {
  getPage(options: PageOptions): Promise>;
  getStatusCounts(): Promise>;
  getById(id: string): Promise;
  create(dto: CreateDto): Promise;
  update(id: string, dto: UpdateDto): Promise;
  delete(id: string): Promise;
}

Known Platform Limitations:

  • Dataverse OData (Code Apps): $skip is supported via the generated service's IGetAllOptions.

Use skip + top for standard server-side paging, or skipToken for continuation-based paging.

  • Power Pages Web API: $skip is NOT supported on the /_api/ endpoint. Use FetchXML paging

instead — FetchXML supports page and count attributes for true server-side pagination, plus a paging-cookie for efficient continuation. Wrap FetchXML queries via /_api/ using ?fetchXml=.


NON-NEGOTIABLE: Server-Side Data Delegation

> If the user will see sorted, filtered, or paginated data — the server must do the > sorting, filtering, and paginating. The client only renders what the server returns. > No exceptions.

These rules are MANDATORY for every service implementation and every list/grid screen. Violations cause wrong data, wrong counts, and systems that break at scale.

Rule 1: Sorting MUST be server-side
// BAD — client-side sort only sorts the current page
const sorted = [...items].sort((a, b) => a.price - b.price);

// GOOD — sort parameter sent to server
list(filters, page, pageSize, sortColumn: "price", sortDirection: "asc")
// Server adds: $orderby=zava_price asc

Why: Sorting 25 items on page 1 does NOT produce the same result as sorting the entire 10,000-row dataset and returning the first 25. Client-side sort is a lie when pagination is active.

In-memory implementation exception: In-memory services hold all data in arrays, so sorting the array then slicing for pagination is correct. But the interface MUST accept sort parameters so that real implementations can delegate to the server.

Rule 2: Filtering MUST be server-side
// BAD — fetch all, filter in browser
const all = await service.list({}, 1, 1000);
const filtered = all.items.filter(x => x.status === "Active");

// GOOD — filter parameter sent to server
const result = await service.list({ status: "Active" }, 1, 25);
// Server adds: $filter=statuscode eq 1

Why: Client-side filtering makes totalCount wrong. If the server says 100 records total but only 12 match the filter, the pagination shows 4 pages but pages 2-4 are empty. Users see ghost pages.

corollary: Every filter on the UI MUST have a corresponding server-side query parameter. If the server can't filter by a field, either:

  1. Add that field to the server query (preferred), or
  2. Remove the filter from the UI (acceptable), or
  3. Document it as a known limitation with a clear comment (last resort)

Never silently filter client-side and pretend the pagination is correct.

Rule 3: Pagination MUST be server-side
// BAD — fetch all, slice in browser
const all = await fetch("/api/properties");       // returns 10,000 rows
const page = all.slice((pageNum - 1) * 25, pageNum * 25);

// GOOD — page parameters sent to server
const result = await fetch("/api/properties?$top=25&$skip=50");  // returns 25 rows

Why: Fetching 10,000 records to display 25 wastes bandwidth, memory, and time. Response times degrade linearly with dataset size.

When true server-side paging isn't possible (e.g., Dataverse OData lacks $skip):

  • Use $skiptoken continuation-based paging
  • Use FetchXML page + count attributes
  • Use over-fetch + slice ONLY as a documented last resort with a hard cap (e.g., max 500 rows)
  • NEVER silently over-fetch without documenting the limitation
Rule 4: Search/text queries MUST be server-side
// BAD — client-side text search
const results = items.filter(x =>
  x.name.toLowerCase().includes(searchText.toLowerCase())
);

// GOOD — search sent to server
const result = await service.list({ search: searchText }, 1, 25);
// Server adds: $filter=contains(zava_name, 'searchText')

Why: Client-side search only searches the current page. A user searching for "Penthouse" won't find it if it's on page 4 and they're viewing page 1.

Rule 5: Counts and aggregates MUST be server-side
// BAD — fetch all records to count them
const all = await service.list({}, 1, 99999);
const activeCount = all.items.filter(x => x.status === "Active").length;

// GOOD — use server-side count or aggregate
const counts = await service.getStatusCounts();
// Server uses: GET /api/properties?$count=true&$filter=statuscode eq 1
// Or: FetchXML aggregate queries

Why: Loading 10,000 records to display a dashboard number "Active: 47" is catastrophically wasteful. Dashboard screens with multiple KPIs multiply the problem.

Rule 6: The service interface MUST express server-side capabilities

Every list method in the service interface MUST accept:

  • filters — typed filter object (not any)
  • page and pageSize — pagination parameters
  • sortColumn and sortDirection — sort parameters
  • search — text search parameter (if the entity supports it)
// GOOD — interface makes delegation explicit
interface IPropertyService {
  list(
    filters?: PropertyFilters,
    page?: number,
    pageSize?: number,
    sortColumn?: string,
    sortDirection?: SortDirection
  ): Promise>;
}

If a parameter exists in the interface, BOTH the in-memory AND real implementations MUST honor it. The in-memory implementation sorts/filters/pages its arrays. The real implementation delegates to the server.

Verification Checklist

Before marking any list screen as complete, verify:

  • [ ] Sort column + direction are sent to the service layer (not sorted after fetch)
  • [ ] All active filters are sent to the service layer (not filtered after fetch)
  • [ ] Page number + page size are sent to the service layer (not sliced after fetch)
  • [ ] totalCount in PagedResult reflects the filtered count, not the total table size
  • [ ] Text search is sent to the service layer (not searched after fetch)
  • [ ] Dashboard counts use server-side aggregation, not fetch-all-and-count
  • [ ] The in-memory implementation honors ALL the same parameters as the real implementation
  • [ ] No Array.prototype.sort() or .filter() on fetched PagedResult.items in screen components
  • [ ] React Query hooks for paged data use placeholderData: keepPreviousData
  • [ ] Both in-memory and real service have matching SORT_FIELDS / SORT_MAP keyed by DataGrid columnId
  • [ ] Detail screen hooks expose isLoading, isError, and data separately (not combined into one check)
  • [ ] Every mutation onSuccess calls useNotificationStore.getState().enqueue() with a success message
  • [ ] Every mutation onError calls useNotificationStore.getState().enqueue() with an error message
  • [ ] In-memory sort uses typed accessor SORT_FIELDS map (not generic string-key bracket notation)
Rule 7: Sort Map Contract Between UI and Service

The DataGrid columnId values are the contract between UI columns and service sort logic. Both in-memory and real implementations MUST define a map from these IDs to their sort mechanism.

// In-memory: map columnId → accessor function
const SORT_FIELDS: Record string | number | Date> = {
  title: (p) => p.name.toLowerCase(),
  price: (p) => p.price,
  status: (p) => p.status,
};

// Real (Dataverse): map columnId → OData column name
private static SORT_MAP: Record = {
  title: "prefix_name",
  price: "prefix_price",
  status: "statuscode",
};

Why two maps? The in-memory service sorts arrays using accessor functions. The Dataverse service builds $orderby clauses using column names. Both are keyed by the same columnId strings from the DataGrid, so the UI never knows which implementation is active.

Default sort: When sortColumn is not in the map, fall back to a sensible default (e.g., modifiedOn desc). Never throw or return unsorted data.

Rule 8: Client-Side Search — Last Resort with Correct Pagination

Some searches are impossible server-side (e.g., OData cannot contains() on lookup display names). When client-side filtering is unavoidable:

  1. Document it with a comment: // Client-side: OData can't filter on lookup display names
  2. Fix the pagination count — use the filtered length, not totalCount:
// Client-side search on returned items (documented limitation)
const filteredItems = useMemo(() => {
  if (!search) return data?.items ?? [];
  const q = search.toLowerCase();
  return (data?.items ?? []).filter(item =>
    item.displayName?.toLowerCase().includes(q)
  );
}, [data, search]);

// Pagination uses filtered count when client-side search is active
  1. Never use client-side search as the default approach — it only searches the current page.

Exhaust server-side options first.


Mock Data Indicator

When the app is running with in-memory mock data, display a visible indicator:

// In your AppShell or TopBar component:
{serviceRegistry.isUsingMockData && (
  MOCK DATA
)}

This prevents confusion during testing and demos. The user should always know whether they're looking at real or simulated data.


Service Boundary Validation Rules

Data entering the app from external sources (APIs, AI services, user input) must be validated and normalized at the service boundary before reaching the UI layer.

  1. Clamp numeric values from external APIs — AI-generated scores, ratings, or computed

values can exceed expected ranges (e.g., a score of 105 when the UI expects 0–100). Clamp at the service mapper, not in the component:

``typescript // In the domain mapper (service layer): score: Math.max(0, Math.min(100, raw.aiScore ?? 0)), ``

  1. Wrap JSON.parse of external API responses in try/catch — External APIs may

return malformed JSON, HTML error pages, or empty bodies. Always catch parse errors and throw a user-friendly error:

``typescript let parsed: ExternalResponse; try { parsed = JSON.parse(responseText); } catch { throw new Error("Invalid response from external service"); } ``

  1. Date formatting: use toISOString(), never String()String(dateObject)

produces non-ISO format like "Sat Feb 01 2026 00:00:00 GMT..." which is unparseable by many backends. Always use date instanceof Date ? date.toISOString() : date for reliable serialization.

  1. Date-only fields: append T00:00:00 for local-time interpretation — When saving

a date-only value (e.g., a closing date), append T00:00:00 to the ISO string to prevent UTC interpretation that shifts the date backward in negative-offset timezones.

  1. InMemory services: resolve lookup display names via lazy import — When an

in-memory create() or update() method needs to resolve a lookup ID to a display name (e.g., to populate a categoryName field), use lazy import() of the service registry to prevent circular module dependencies:

``typescript async create(dto: CreateDto): Promise { // Lazy import to break circular dependency const { getService } = await import("../serviceRegistry"); const categoryService = getService("categories"); const category = await categoryService.getById(dto.categoryId); // ... populate entity with category.name } ``


Step 2: Build In-Memory Implementations

Create implementations that store data in arrays. These run entirely in the browser with zero external dependencies.

See resources/in-memory-implementation.md for the full InMemoryProjectService class implementing all interface methods with array storage.

Test Data Guidelines

  • Seed enough records to test pagination, empty states, and scrolling (10–50 records)
  • Include edge cases — empty strings, long names, special characters, null-like values
  • Cover all enum values — ensure every status/category has at least one record
  • **Use realistic values*

Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet, be the first.

Versions

  • v0.1.0 Imported from the upstream source.