| name | ioc-development-pattern |
| description | 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-memory data", "mock data", "test data first", "service interface", "swap implementation", "local development pattern", "dev without dataverse", "dev without database", "develop locally", "mock service", "in-memory service", "service abstraction", "data service pattern", "service layer", "react query", "tanstack query", "query key factory", "cache invalidation", "optimistic update", "domain model", "dto pattern". |
| user-invocable | false |
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
export interface IEntityService<T> {
getAll(options?: QueryOptions): Promise<T[]>;
getById(id: string): Promise<T | null>;
create(entity: Omit<T, "id">): Promise<T>;
update(id: string, changes: Partial<T>): Promise<void>;
delete(id: string): Promise<void>;
}
export interface QueryOptions {
filter?: string;
orderBy?: string;
top?: number;
select?: string[];
}
Domain Model Types
Each domain entity gets three type definitions:
- Domain model — what the app works with (full shape with proper types)
- CreateDto — fields for creating a new record (only user-controlled fields)
- UpdateDto — fields for updating (all optional, only include what changed)
- Filters — query parameters for list operations
export interface Project {
id: string;
name: string;
description: string | null;
status: "Active" | "Completed" | "On Hold";
startDate: Date;
budget: number;
createdOn: Date;
modifiedOn: Date;
}
export interface CreateProjectDto {
name: string;
description?: string;
status?: Project["status"];
startDate?: Date;
budget?: number;
}
export interface UpdateProjectDto {
name?: string;
description?: string;
status?: Project["status"];
startDate?: Date;
budget?: number;
}
export interface ProjectFilters {
status?: Project["status"];
search?: string;
}
Entity Service Interface
export interface IProjectService extends IEntityService<Project> {
getByStatus(status: Project["status"]): Promise<Project[]>;
}
export interface ITaskService extends IEntityService<Task> {
getByProject(projectId: string): Promise<Task[]>;
}
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<string, string>;
}
interface PagedResult<T> {
items: T[];
totalCount: number;
page: number;
pageSize: number;
}
interface IEntityService<T> {
getPage(options: PageOptions): Promise<PagedResult<T>>;
getStatusCounts(): Promise<Record<string, number>>;
getById(id: string): Promise<T | null>;
create(dto: CreateDto): Promise<T>;
update(id: string, dto: UpdateDto): Promise<T>;
delete(id: string): Promise<void>;
}
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=<encoded-xml>.
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
const sorted = [...items].sort((a, b) => a.price - b.price);
list(filters, page, pageSize, sortColumn: "price", sortDirection: "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
const all = await service.list({}, 1, 1000);
const filtered = all.items.filter(x => x.status === "Active");
const result = await service.list({ status: "Active" }, 1, 25);
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:
- Add that field to the server query (preferred), or
- Remove the filter from the UI (acceptable), or
- 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
const all = await fetch("/api/properties");
const page = all.slice((pageNum - 1) * 25, pageNum * 25);
const result = await fetch("/api/properties?$top=25&$skip=50");
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
const results = items.filter(x =>
x.name.toLowerCase().includes(searchText.toLowerCase())
);
const result = await service.list({ search: searchText }, 1, 25);
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
const all = await service.list({}, 1, 99999);
const activeCount = all.items.filter(x => x.status === "Active").length;
const counts = await service.getStatusCounts();
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)
interface IPropertyService {
list(
filters?: PropertyFilters,
page?: number,
pageSize?: number,
sortColumn?: string,
sortDirection?: SortDirection
): Promise<PagedResult<Property>>;
}
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:
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.
const SORT_FIELDS: Record<string, (item: Entity) => string | number | Date> = {
title: (p) => p.name.toLowerCase(),
price: (p) => p.price,
status: (p) => p.status,
};
private static SORT_MAP: Record<string, string> = {
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:
- Document it with a comment:
// Client-side: OData can't filter on lookup display names
- Fix the pagination count — use the filtered length, not
totalCount:
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
totalItems={search ? filteredItems.length : data?.totalCount ?? 0}
...
/>
- 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:
{serviceRegistry.isUsingMockData && (
<Badge appearance="tint" color="warning">MOCK DATA</Badge>
)}
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.
-
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:
score: Math.max(0, Math.min(100, raw.aiScore ?? 0)),
-
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:
let parsed: ExternalResponse;
try {
parsed = JSON.parse(responseText);
} catch {
throw new Error("Invalid response from external service");
}
-
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.
-
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.
-
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:
async create(dto: CreateDto): Promise<Entity> {
const { getService } = await import("../serviceRegistry");
const categoryService = getService("categories");
const category = await categoryService.getById(dto.categoryId);
}
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 — stakeholders will see this data in demos
- Optionally simulate latency — add
await new Promise(r => setTimeout(r, 200)) at the
top of methods to test loading states and spinners
Step 3: Create a Service Registry
Centralize service resolution so the app consumes interfaces, not implementations.
One swap point controls which implementations are active.
Simple Factory Pattern
import type { IProjectService, ITaskService } from "./interfaces";
import { InMemoryProjectService } from "./in-memory/InMemoryProjectService";
import { InMemoryTaskService } from "./in-memory/InMemoryTaskService";
const services = {
projects: new InMemoryProjectService() as IProjectService,
tasks: new InMemoryTaskService() as ITaskService,
};
export function getService<K extends keyof typeof services>(name: K): (typeof services)[K] {
return services[name];
}
export function _resetForTesting(): void {
services.projects = new InMemoryProjectService();
services.tasks = new InMemoryTaskService();
}
Test isolation is critical. The registry uses module-level singletons — state persists between tests unless explicitly reset. Always call _resetForTesting() in beforeEach() during unit tests. See resources/testing-strategy.md for full testing patterns.
React Context Pattern (Alternative)
For React apps, a context provider gives components access to services via hooks:
import { createContext, useContext, type ReactNode } from "react";
import type { IProjectService, ITaskService } from "./interfaces";
import { InMemoryProjectService } from "./in-memory/InMemoryProjectService";
import { InMemoryTaskService } from "./in-memory/InMemoryTaskService";
interface Services {
projects: IProjectService;
tasks: ITaskService;
}
const defaultServices: Services = {
projects: new InMemoryProjectService(),
tasks: new InMemoryTaskService(),
};
const ServiceContext = createContext<Services>(defaultServices);
export function ServiceProvider({ children, services }: { children: ReactNode; services?: Partial<Services> }) {
const merged = { ...defaultServices, ...services };
return <ServiceContext.Provider value={merged}>{children}</ServiceContext.Provider>;
}
export function useServices(): Services {
return useContext(ServiceContext);
}
Usage in components:
const { projects } = useServices();
const allProjects = await projects.getAll({ top: 50 });
Step 4: Build the UI Against Interfaces
All components import from the service registry or context — never from a concrete
implementation. This means the entire UI can be built, tested, and demoed with zero
backend connectivity.
Simple Approach (useState + useEffect)
import { useEffect, useState } from "react";
import { useServices } from "../services/ServiceContext";
import type { Project } from "../services/interfaces";
export function ProjectList() {
const { projects } = useServices();
const [items, setItems] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
projects.getAll({ orderBy: "name asc", top: 50 }).then((data) => {
setItems(data);
setLoading(false);
});
}, [projects]);
if (loading) return <div>Loading...</div>;
return (
<ul>
{items.map((p) => (
<li key={p.id}>{p.name} — {p.status}</li>
))}
</ul>
);
}
Preferred: React Query (TanStack Query) Integration
For production React apps, use React Query (@tanstack/react-query) instead of raw useEffect.
It provides caching, background refetching, loading/error states, and mutation hooks out of the box.
npm install @tanstack/react-query
Wrap your app with QueryClientProvider:
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
retry: 1,
},
},
});
export const App = () => (
<QueryClientProvider client={queryClient}>
{/* app content */}
</QueryClientProvider>
);
See resources/react-query-hooks.md for the complete React Query integration:
query key factory, query hooks, mutation hooks with cache invalidation,
optimistic updates, and component usage patterns.
At this stage you have a fully functional app running on in-memory data.
Iterate on the UI, get stakeholder feedback, refine flows — all without touching a backend.
Step 5: Implement Real Backend Services
When the UI is approved and stable, create implementations that connect to the real data source.
The implementation translates between your domain interface and the backend's raw API.
Service Implementation Pattern
Every real service method should:
- Validate required inputs before calling the API
- Call the platform API with properly mapped payloads
- Transform the raw response to domain model types via
toDomainModel()
- Catch errors and throw user-friendly messages
- Log the raw error for debugging
See resources/real-service-implementation.md for the complete RealProjectService class,
key implementation principles, and graceful degradation patterns.
For platform-specific implementation guidance:
- Dataverse (Code Apps): Read
../power-apps-code-apps/resources/dataverse-integration.md
- Azure SQL (Code Apps): Read
../power-apps-code-apps/resources/connector-azure-sql.md
- Power Pages Web API: Read
../power-pages/resources/webapi-integrate.md
Step 6: Swap to Real Services
Update the service registry to use the real implementations. You can swap all at once
or incrementally — one entity at a time:
import type { IProjectService, ITaskService } from "./interfaces";
import { RealProjectService } from "./real/RealProjectService";
import { InMemoryTaskService } from "./in-memory/InMemoryTaskService";
const services = {
projects: new RealProjectService() as IProjectService,
tasks: new InMemoryTaskService() as ITaskService,
};
export function getService<K extends keyof typeof services>(name: K): (typeof services)[K] {
return services[name];
}
No UI code changes needed. Components still call projects.getAll() — the registry
routes to the real implementation.
When to Swap
- Build UI first — Get full UX approval on in-memory data
- Set up the data source — Create Dataverse tables, Azure SQL stored procedures, etc.
- Implement one service — Start with the most critical entity
- Test the swap — Verify data flows correctly, handle edge cases in real data
- Swap remaining services — One at a time until fully connected
- Remove in-memory implementations — Or keep them for unit tests
Validation at Each Step
After completing each step, run npm run build (or npx tsc --noEmit) to verify there are no type errors. Fix any errors before proceeding to the next step. After Step 4, run the app (npm run dev) and manually verify the in-memory data renders correctly.
Important Notes
- Keep interfaces stable — Once the UI is built against an interface, changing it requires
updating all implementations. Design interfaces carefully upfront.
- In-memory implementations are test fixtures — Reuse them in unit tests. They're fast,
deterministic, and require no mocking libraries.
- Don't over-engineer the registry — A simple factory or context provider is sufficient
for most apps. You don't need a full DI container.
- Test data should cover edge cases — Empty lists, long strings, special characters,
maximum field lengths, null/undefined values, all enum variants.
- Map types at the boundary — Real implementations should translate between backend types
(e.g., all-string Dataverse models, ResultSets wrappers from Azure SQL) and your clean
domain types. Components never deal with raw backend shapes.
- Error handling in the interface — Decide upfront whether methods throw or return
error objects. Be consistent across all implementations.
NON-NEGOTIABLE: Detail Screen Data Loading
Detail screens (record forms) MUST distinguish between loading, error, and not-found states.
Combining them into a single check causes infinite spinners when a record doesn't exist.
The Bug
const { data: property, isLoading } = useProperty(id);
if (isLoading || !property) return <Spinner label="Loading..." />;
When id is valid but the record was deleted, isLoading becomes false and data is null.
The !property check keeps showing the spinner forever — the user has no way to know
the record doesn't exist.
The Fix
const { data: property, isLoading, isError } = useProperty(id);
if (isLoading) return <Spinner label="Loading property..." />;
if (isError || !property) {
return (
<MessageBar intent="error">
<MessageBarBody>
Property not found. It may have been deleted.
<Button appearance="subtle" onClick={() => navigate("/properties")}>
Back to list
</Button>
</MessageBarBody>
</MessageBar>
);
}
Rule
Every detail screen MUST have THREE distinct states:
- Loading —
isLoading is true → show Spinner
- Error / Not Found —
isError or data is null → show error message with back navigation
- Loaded — data is available → render the form
Never combine loading and not-found into one conditional.
Resource Files
| Resource | Description |
|---|
resources/in-memory-implementation.md | Full InMemoryProjectService reference implementation with array storage |
resources/react-query-hooks.md | Query key factory, query/mutation hooks, cache invalidation, optimistic updates, component usage |
resources/real-service-implementation.md | Full RealProjectService class, toDomainModel(), implementation principles, graceful degradation |
resources/testing-strategy.md | Testing patterns for the IoC service layer — unit testing in-memory and real services, testing React Query hooks, service registry test isolation, Vitest configuration |
resources/error-handling.md | Structured error handling pattern — typed errors, error boundary integration, React Query error states, toast notifications |