Template Rules (for AI) β
This project is built to be maintained largely by an AI assistant. This page is the contract the AI follows when it writes code in a Host or a Remote. The rules are grouped into:
- Common Rules β apply to both Host and Remote.
- Host template β what the AI may / may not do in the Host.
- Remote template β what the AI may / may not do in a Remote.
Start here and follow the Common Rules on every change.
The Common Rules are ordered as a learning path: understand the machine (1β4) β shared config (5) β naming conventions (6) β environment (7) β where code & data live (8β10) β capabilities: how to fetch, type, and build UI (11β14) β put it together and build a feature (15). Read top-to-bottom once; after that, route by the table below.
How to use these rules β
Don't read all 15 every time. Route by what you're doing, obey the hard prohibitions, and stop to ask at the marked decision points. Every rule below also opens with a When / Do / Don't lead so you can scan it in one line.
Start here β intent β rule β
| You're about to⦠| Read |
|---|---|
| Build a feature / page | 15 (+ 8, 11, 13) |
| Name a variable, function, or handler args | 6 |
| Consume an OData URL / entity | 12 |
| Fetch data (REST or OData) | 11 |
| Get one record / detail from a DataSource | 11 (reuse the source β store().load({ take: 1 }), no 2nd fetcher) |
Import across apps, or from datas / types / odata | 2 |
| Create a store or composable | 8 |
| Add static / constant data | 9 |
| Add a global helper | 10 |
| Need validation / notif / Excel / JSON helpers | 14 |
| Add an API endpoint, cookie, JWT, or menu entry | 5 |
| Handle a secret / base URL / port | 7 |
| Build UI | 13 |
Never do β
- Install an external HTTP library (axios, ofetch, ky, raw
fetchwrapper) β Rule 11. - Build a second fetcher for a row you already hold a DataSource for β reuse the source (
source.store().load({ take: 1 })), don't call a freshmonoOdataFetchβ Rule 11. - Edit anything in
.apps/β Rule 3. - Hand-edit generated files (
auto-imports.d.ts,components.d.ts,typed-router.d.ts,src/odata/DTO/) β Rule 4. - Hand-write OData entity types β generate them β Rule 12.
- Reuse a
defineStorekey or an export name across apps β Rule 8. - Use
dataSource.store().byKey()for a single row (load({ take: 1 })instead) β Rule 11. - Manage the theme from a Remote β don't import the theme CSS or call
applyTheme/applyFlavorin a Remote; the Host owns the theme β Host template & Theme. - Rewire
vite.config.ts/tsconfig.jsonaliases on your own β Rules 1β2.
When to stop and ask the user β
- Editing
vite.config.tsor the@app-namealias paths (Rules 1β2). - Touching
mono.config.tsextends/apps(Rule 5). - Naming a new page/route (Rule 15).
- Whether a new page should be in the menu / deployable to the other app (Rule 11).
Glossary β
| Term | What it is |
|---|---|
mono-utils | Core ecosystem package β mergeEcosystem, config, env, fetching. |
mono-utils/fetching | The only sanctioned fetch layer (monoFetch, monoCreateFetcher, β¦). Rule 11. |
mono-utils/config | defineConfig for mono.config.ts. Rule 5. |
mono-helper | Shared UI + table helpers (monoDataGrid). |
mono-helper/ui | The <mono-*> Lit web components. Rule 13. |
esw-utils | Value-add helpers (validation, notif, Excel, JSON, EswOdataMapTypes). Rules 12, 14. |
@mono-vue / @mono-host (+ -root) | Role-based import aliases β own ./src vs the other app's. Rule 2. |
mergeEcosystem | Vite helper that merges the other app's pages/components/stores. Rule 1. |
mono.config.ts | Shared cookie / JWT / API / menu config + sync source. Rule 5. |
.apps/ | Synced copy of the other app. Read-only. Rule 3. |
| DataSource | A live, reactive handle to an OData endpoint (paging/filter/sort). Rule 11, DataSource. |
Common Rules β
1. The Vite config is the core β ask before changing it β
When: always be aware of it; act only when a change to build wiring seems needed. Do: treat
vite.config.tsas read-only and understand what its plugins give you. Don't: add/remove/rewiremergeEcosystem,resolve.alias, orserver.fs.allowwithout asking.
Everything under /src is wired by vite.config.ts. It is fixed infrastructure β the programmer (and the AI) rarely touches it. Almost every convenience you rely on in /src is set up here, which is exactly why you don't edit it casually: a small change can silently break auto-imports, routing, or the link between Host and Remote.
WARNING
Treat vite.config.ts as read-only. If a change there seems necessary β ask the user first, especially for the three Host β Remote settings below.
What each plugin gives you (DX) β
These plugins are why /src code is so light β most of them mean you don't write imports:
| Plugin | What it does |
|---|---|
@vitejs/plugin-vue | Compiles .vue SFCs. isCustomElement lets mono-, dx- (and ui5- in a Remote) tags through untouched so the browser renders the web components. |
unplugin-auto-import (AutoImport) | Auto-imports vue, vue-router, @vueuse/core, pinia, unhead, and everything in src/stores and src/composables. So ref, computed, useRouter, defineStore, and your own useBrand() work with no import line. Generates a .d.ts for type support. |
unplugin-vue-components (Components) | Auto-registers components from src/components β drop a .vue there and use it in any template without importing. directoryAsNamespace turns the folder into a prefix (e.g. components/brand/Table.vue β <BrandTable>). |
vue-router/vite (VueRouter) | File-based routing β a file in src/pages becomes a route automatically. No manual route table. |
vite-plugin-vue-layouts (Layouts) | Wraps pages in layouts from src/layouts; defaultLayout: 'default'. |
unocss/vite (UnoCSS) | Atomic utility-class CSS engine. |
Host-only extras: vite-plugin-vue-devtools (the in-app Vue devtools) and @sentry/vite-plugin (uploads source maps when VITE_SENTRY_DSN is set). Remote-only extras: vite-plugin-mkcert (local HTTPS when VITE_HTTPS=true) and a server.proxy for /api and /odata during dev.
The Host β Remote connection (do not touch without asking) β
The Host and Remote are only joined in three places in this file. These are the core of the whole mono-repo β changing them wrongly breaks the link between apps:
mergeEcosystem(...)(frommono-utils) β merges the other app'ssrc/pages,src/components,src/stores/shared, andsrc/composables/sharedinto this app's routing, auto-import, and component registration. This is how a Host sees the Remote's modules (and a Remote sees the Host's shared code).resolve.aliasβ an alias for the Host's source and one for the Remote's source (each with a project-root variant), generated bymonoAlias()(Rule 2) β not hand-listed. Inside either app, the role it is points at its own./src, and the role it isn't points into the synced./.apps/<other>/src. So you import by role from anywhere β the Host alias always reaches Host code and the Remote alias always reaches Remote code, no matter which app you're working in. This is the import connection β see Rule 2.server.fs.allowβ grants Vite filesystem access to./.appsso it can actually read the synced other app's source (see Sync).
Everything else (server.port, build, dotenv) is plumbing. Do not add, remove, or rewire any of the above on your own β confirm with the user first.
2. Import connection β the @app-name aliases β
When: importing across apps, or from
datas/types/odata(anything not auto-imported). Do: import by role β@mono-vue/.../@mono-host/...; letmonoAlias()generate the Vite side, and keeptsconfig.jsonpathsmatching those names. Don't: hand-write the Vite alias paths, or assumedatas/types/odataare auto-imported.
Very important β without this, apps can't reach each other's files
The @app-name aliases are how a Host and a Remote import each other's code, and how an app reaches its own folders that aren't auto-imported. If they're missing or wrong, cross-app imports β and even your own @mono-vue/types imports β stop resolving and the app won't build. Treat them as core infrastructure (part of Rule 1): don't rewire them without asking.
The same alias names live in two places, but only one is hand-maintained:
vite.config.tsβresolve.alias(and the jiti aliasgetMonoConfiguses) β used by the bundler / dev server so the import actually works at runtime. Generated automatically bymonoAlias()β see below.tsconfig.jsonβcompilerOptions.pathsβ used by TypeScript and your editor so the types resolve (type-checking, go-to-definition). Still declared by hand, so it must match the namesmonoAlias()produces.
monoAlias() builds the Vite aliases for you β
vite.config.ts no longer hand-lists the alias map. One call from mono-utils/config/node derives it β the own name from mono.config.ts (name), the remotes by scanning .apps/:
ts
import { getMonoConfig, monoAlias } from 'mono-utils/config/node'
export default defineConfig(async () => {
const alias = monoAlias({
dirname: fileURLToPath(new URL('.', import.meta.url)),
})
// jiti needs the same aliases to parse mono.config.ts (which imports via @mono-host/β¦)
const monoConfig = await getMonoConfig({ jitiOptions: { alias } })
return {
resolve: { alias },
// β¦
}
})It emits, for an app named mono-host with mono-vue synced into .apps/:
| Alias | Points at |
|---|---|
@mono-host / @mono-host-root | own ./src / ./ |
@mono-vue / @mono-vue-root | ./.apps/mono-vue/src / ./.apps/mono-vue |
@mono-apps | ./.apps |
So adding or renaming a remote needs no vite.config.ts edit β sync it into .apps/ (Rule 3) and the alias appears on its own. The one thing still manual is the matching tsconfig.json paths entry (TypeScript can't run the helper).
What the aliases point at (role-based) β
Each app aliases its own role to its own ./src, and the other app's role into the synced ./.apps/<other>/src (see Sync). So you always import by role, from either app:
| Alias | In the Remote (mono-vue) | In the Host (mono-host) |
|---|---|---|
@mono-vue | ./src (its own) | ./.apps/mono-vue/src (synced) |
@mono-host | ./.apps/mono-host/src (synced) | ./src (its own) |
@mono-vue-root / @mono-host-root | the matching project-root variants | the matching project-root variants |
(The Host also exposes @mono-apps β ./.apps.) The upshot: @mono-host/... always reaches Host code and @mono-vue/... always reaches Remote code, no matter which app you're in.
Repo name vs app name: the GitHub repos are
mono-vue-host(Host) andmono-vue-remote(Remote), but the internal appnameinmono.config.tsstaysmono-host/mono-vueβ and the@mono-host/@mono-vuealiases are derived from thatname, so they're unchanged.
Why aliases at all β what auto-import doesn't cover β
Auto-import (Rule 1) only wires up src/stores, src/composables, and src/components. Everything else you reach with an explicit import. Folders like src/datas, src/types, and src/odata are deliberately not auto-imported β their names are far too generic and common (every app has a types and a datas), so auto-importing them would collide and pollute the global namespace. Instead you import them explicitly through the alias β and that same alias is what lets one app reach the other app's datas / types / odata:
ts
import type { DTO_BrandTypes } from '@mono-vue/types' // a Remote type
import table from '@mono-vue/datas/table' // a Remote's static data
import { QDTO_Brand } from '@mono-vue/odata/DTO/QDefault' // a Remote's generated ODataSo the rule of thumb: auto-imported folders (stores / composables / components) need no import line; everything else β especially datas, types, and odata β is reached through the @app-name alias. That alias is the backbone connecting the two apps; keep it intact.
3. Never edit .apps/ β it's synced β
When: always β whenever you see a path under
.apps/. Do: change the other app in its own repo, thenpnpm mono:sync. Don't: create, edit, or delete anything inside.apps/by hand.
.apps/ is where this app keeps a copy of the other app it's connected to β a Host stores the Remote there, a Remote stores the Host there. It's generated content, and the whole cross-app setup reads from it: the @mono-host / @mono-vue aliases resolve into it, mergeEcosystem pulls pages/components/stores from it, and server.fs.allow grants access to it (all of Rule 1's Host β Remote links).
Do not touch .apps/
Never create, edit, or delete files inside .apps/ by hand. It feeds routing, auto-imports, and the build directly, so manual changes will break things β and the next sync overwrites them anyway. The only way to update it is to re-sync:
pnpm mono:sync(the script lives in your package.json). See Sync. Need a change in the other app? Make it in that app's own repo and sync.
4. Never edit generated files β
When: always β before touching any
*.d.tsorsrc/odata/DTO/file. Do: change the source (store/composable/component/page, or the OData metadata) and regenerate. Don't: hand-editauto-imports.d.ts,components.d.ts,typed-router.d.ts, orsrc/odata/DTO/.
Several files are produced by the tooling and regenerated on every dev / build β hand edits are silently lost and only hide the real problem. Treat them as read-only:
auto-imports.d.tsβ generated byunplugin-auto-importcomponents.d.tsβ generated byunplugin-vue-componentstyped-router.d.tsβ generated byvue-router(its header literally says "DO NOT MODIFY THIS FILE")- everything under
src/odata/DTO/β generated byodata2tsfrom the OData$metadata
Don't hand-edit generated output
Never edit the files above. They're regenerated on dev/build, so your changes vanish β and editing the output instead of the source hides the actual cause. Commit them to version control, but to change what they contain, edit the source: a store / composable / component / page for the *.d.ts files, or the OData metadata (then regenerate) for the src/odata/DTO/ types.
Same principle as Rule 3 (.apps/ is synced, not edited): generated or synced output is read-only β fix the thing that produces it.
5. mono.config.ts β the shared config + sync core β
When: adding an API endpoint, cookie, JWT, or menu entry. Do: edit
fetching.api/auth/cookie/jwt/menufreely. Don't: changeextendsorappswithout asking β they wire the two apps together.
mono.config.ts (built with defineConfig from mono-utils/config) is the single source of truth that ties a Host and a Remote together and holds their shared cookie / JWT / API / menu config. Unlike vite.config.ts, you do edit this file regularly β adding an API endpoint, a cookie, or a menu entry is normal. Only two fields actually wire the two apps together; treat those with care.
What each field does β
| Field | What it does |
|---|---|
name | This app's identity within the ecosystem. |
extends | Merges the other app's mono.config into this one, so cookies, JWT, APIs, and menu compose across Host and Remote. (Host β Remote link.) |
apps | The app(s) this one pulls when you run pnpm mono:sync β each entry's url is a GitHub ref and envToken names the env var holding the access token. (Host β Remote link β see Sync.) |
fetching.api | Named REST / OData endpoints and their source constructors. Add your APIs here; call them by name with configBaseUrl. See Data Fetching. |
fetching.auth | Picks which cookie is the access token vs the refresh token, and which one (use) is sent on each request. |
cookie | Declares the cookies (name, split) whose values hydrate into mono state. |
jwt | Decodes the token / refreshToken from their cookies into JWT state (prod reads by cookie name; dev uses the decoded payload). |
menu | This app's navigation entries (title, url, icon, nested items) β how a Remote's modules show up in the Host's nav. See Config. |
Host β Remote: handle with care β
extends and apps are the link between the two apps β extends merges the other app's config, and apps declares what mono:sync pulls. Changing either alters how Host and Remote connect, so confirm with the user before editing them (same as the three vite.config.ts settings in Rule 1).
Everything else β fetching.api, auth, cookie, jwt, menu β is normal day-to-day editing. Add the API, cookie, or menu entry your feature needs.
6. Naming β consistent prefixes & object props β
When: naming any variable / function, or passing arguments to a handler. Do: group by a shared leading prefix (
data*,dataSource*,input*), name functions verb-first (openModal,addBudget), and pass arguments as one typed object βopenModal({ item }: { item: Budget }). Don't: flip the prefix per entity (budgetData), pass positional scalars (openModal(row.Id, row.Name)), or pass a bare row (openModal(row)).
Consistency is the whole point: when every variable, function, and call site is named the same way, the code reads predictably and the AI (and the next programmer) never has to guess. Pick the leading word by role, keep it identical across entities, and the related names sort and scan together.
Variables β leading prefix is the role, suffix is the entity β
The start of the name says what kind of thing it is; the end says which entity it belongs to. Same role β same leading word, every time. A list is data<Entity>, its DataSource is dataSource<Entity>, an edit/form model is input<Entity>.
ts
// β
role first, entity last β groupable, instantly scannable
const dataBudget = ref<Budget[]>([])
const dataTransaction = ref<Transaction[]>([])
const dataSourceBudget = ref<any>(null)
const dataSourceTransaction = ref<any>(null)
const inputBudget = ref<Budget>({} as Budget)
const inputTransaction = ref<Transaction>({} as Transaction)
// β entity-first / mixed prefixes β unrelated-looking, hard to scan
const budgetData = ref([])
const transactionList = ref([])
const budgetSource = ref(null)So the leading word (data, dataSource, input) is the category and stays fixed; only the trailing entity changes. Two variables for the same role always share their prefix.
Functions β verb-first, same shape β
Functions follow the same idea: a consistent leading verb is the action, the rest is the target. Pair openers with closers, and keep the verb identical across entities.
ts
const openModal = () => { /* β¦ */ }
const closeModal = () => { /* β¦ */ }
const addBudget = () => { /* β¦ */ }
const addTransaction = () => { /* β¦ */ }Arguments β one typed object, always the whole row β
When you call a handler β especially inside a table row / detail loop β pass a single object, never positional arguments, and pass the whole row, not hand-picked fields.
ts
// β
one object prop, whole row, typed β self-documenting and future-proof
const openModal = ({ item }: { item: Budget }) => {
inputBudget.value = item
}
// in the template, inside a row loop:
// <mono-button @click="openModal({ item: row })">Detail</mono-button>ts
// β positional scalars β order-sensitive, breaks the moment you need one more field
const openModal = (id: string, name: string) => { /* β¦ */ }
openModal(row.Id, row.Name)
// β bare row β works, but the call site can't tell what shape is expected
const openModal = (row) => { /* β¦ */ }
openModal(row)Why object-props-with-types wins:
- Self-documenting call site β
openModal({ item: row })names what you pass. - Order-free & extensible β grow to
{ item, mode }later without touching callers. - Typed in one place β
{ item }: { item: Budget }gives autocomplete and catches a wrong row shape right at the call site. Declare the shape insrc/types(Rule 15). - Whole row, not fragments β pass
row, notrow.Id/row.Name; the handler reaches whatever field it needs and you never go back to thread one more argument through.
Combine the two β object destructuring plus an inline type β for the cleanest DX: ({ item }: { item: Budget }).
7. Secrets & env β .env / .env.dev, shared with MONO_ β
When: handling any secret / config value (API key, token, base URL, port). Do: put it in
.env/.env.dev; prefix withMONO_only if another app must read it, elseVITE_. Don't: hardcode it in a page / store /mono.config.ts, or commit real.envvalues.
Any secret or config value an app needs to consume β API keys, tokens, base URLs, ports β goes in an env file at the app root. Never hardcode it in a page, store, or mono.config.ts. There are two files, picked by what you're running:
.env.devβ development. Loaded by the dev scripts (pnpm dev)..envβ production. Loaded by build / preview (pnpm build,pnpm preview).
mono-vue-remote/
βββ .env.dev # development secrets/config
βββ .env # production secrets/config
βββ .apps/
βββ mono-host/
βββ .env.dev # the other app's env, layered in automaticallySyncing env between apps β
You don't merge env files by hand. Every script already runs through mono-env (shipped with mono-utils), which loads this app's root env first, then layers each connected app's matching env file from .apps/ on top β see Environment. So just run the normal scripts from package.json:
json
{
"scripts": {
"dev": "mono env -e .env.dev -- vite",
"build": "mono env -e .env -- vue-tsc && mono env -e .env -- vite build --emptyOutDir",
"preview": "mono env -e .env -- vite preview"
}
}pnpm dev loads .env.dev (root + every app); pnpm build / pnpm preview do the same with .env. The -- <command> part is the real command that runs with the merged environment.
Shared vs private: the MONO_ prefix β
vite.config.ts sets envPrefix: ['VITE_', 'MONO_'], so only those prefixes reach client code. Use them deliberately:
MONO_β¦β the only shared vars. Anything one app must read from another (or the Host shares with Remotes). Read withimport.meta.env.MONO_*. Keep keys unique per app (MONO_HOST_API_URL,MONO_VUE_API_URL) so they don't override each other when apps are layered.VITE_β¦β app-private client config (standard Vite).- no prefix β build/tooling-only secrets. Stay in
process.envfor the command, never shipped to the browser.
Don't commit real secrets
.env / .env.dev hold real values and must not be committed. Commit a .env.example with empty keys instead (the templates already ship one). See Environment for the full load order and flags.
8. Unique exports & store keys β suffix with the app name β
When: always β every store / composable you create. Do: suffix the export name and the
defineStorekey with the app name. Don't: ship a generic name likeuseAuthor a key like'auth'.
Stores and composables are auto-imported (Rule 1), which means a Host and every Remote share one global namespace. Two apps that export the same name β or two Pinia stores that use the same defineStore key β collide silently: only one survives, and you end up calling the wrong code or reading the wrong state with no error to warn you. So make every auto-imported export name and every store key unique by suffixing it with the app name.
Pinia stores β unique variable and unique key β
A store in src/stores has two identifiers, and both must be unique:
ts
// β generic β collides with the Host or another Remote
export const useAuthStore = defineStore('use-my-fetch-auth', () => {
const data = ref()
return { data }
})
// β
both the export and the key carry the app name
export const useAuthStoreMonoVue = defineStore('use-my-fetch-auth-mono-vue', () => {
const data = ref()
return { data }
})- The export variable (
useAuthStoreβ¦) is auto-imported globally. If a Remote and the Host both exportuseAuthStore, auto-import resolves to one of them β you may call a completely different store than you meant to. - The
defineStorekey ('use-my-fetch-authβ¦') is how Pinia stores global reactive state. Two stores sharing a key share the same state slot: if both declareconst data = ref(), thedatayou read is whichever store registered first β the other is silently overridden. This is the dangerous one, because the app still runs.
Use the pattern: variable useAuthStoreAppName, key 'your-key-name-app-name'.
Composables & utils β unique export name too β
The same applies to anything auto-imported from src/composables (Rule 10) β the top-level export must be unique, so suffix it with the app name:
ts
export const useHostHelper = () => { /* β¦ */ } // Host
export const useUtilsHost = () => { /* β¦ */ } // Host
// in a Remote, the mirror: useHelperMonoVue, useUtilsMonoVue, β¦Rule of thumb: if it's auto-imported, its name (and a store's key) must be globally unique β append the app name. Generic names like useAuth, useUtils, or a key like 'auth' are landmines across a Host + its Remotes.
9. Static data lives in src/datas β
When: you have static / constant data (config, option lists, column defs, sample rows). Do: put it in
src/datas/, grouped, with oneexport defaultper group. Don't: inline it in a page / component / store, or scatter named exports.
Any static / constant data β config objects, option lists, lookup maps, column definitions, labels, sample rows β goes in src/datas/. Never inline it in a page, component, or store. Static data tends to be large and turns into a mess when it's scattered or passed around as props, and it's almost always shared across more than one component β keeping it in one place keeps it reusable and out of the way.
Group it, and expose one export default per group so consumers do a single import. A long list of named imports β import { static1, day, am } from '...' β is painful to maintain. Instead, put each piece in its own leaf file, then collect them in an index.ts that default-exports one object.
src/
βββ datas/
βββ table/
βββ users.ts # each leaf: `export default { ... }`
βββ products.ts
βββ orders.ts
βββ invoices.ts
βββ customers.ts
βββ index.ts # groups the leaves + export defaultsrc/datas/table/index.ts β group, then default-export:
ts
import users from './users'
import products from './products'
import orders from './orders'
import invoices from './invoices'
import customers from './customers'
const config = {
users,
products,
orders,
invoices,
customers,
}
export default configNow a consumer imports the whole group once and reaches into it by key:
ts
import table from '@/datas/table'
table.orders // β the orders table config
table.invoices // β the invoices table configAs with everything else, type the data (and put any reused shapes in src/types, per Rule 15).
10. Shared utilities go in src/composables/use-utils.ts β
When: you write a genuinely global / reusable helper (not tied to one feature). Do: put it in
src/composables/use-utils.ts(or a split-out file re-exported from it). Don't: use it for feature-specific helpers β those stay in that feature'suse-<name>-utils.ts(Rule 15).
When you have a reactive helper, a common utility, or a reusable bit of behavior that should be available globally (used across features, not tied to one page), put it in src/composables/use-utils.ts. Everything in src/composables is auto-imported (Rule 1), so it's usable anywhere with no import line.
- This is for genuinely shared / global helpers. Feature-specific helpers still belong in that feature's own
use-<name>-utils.ts(Rule 15). - If a group of utilities grows large, give it its own file in
src/composables/and re-export it throughuse-utils.ts, so consumers still reach everything from one place.
src/composables/
βββ use-utils.ts # global helpers β auto-imported everywhere
βββ use-date-utils.ts # split out when a group grows; re-exported by use-utils.tsts
// src/composables/use-utils.ts
export * from './use-date-utils' // pull a large, split-out group back in
// `ref` is auto-imported β no import needed
export function useToggle(initial = false) {
const on = ref(initial)
const toggle = () => (on.value = !on.value)
return { on, toggle }
}11. Data fetching β always use mono-utils/fetching β
When: always β any data access. Do: use
mono-utils/fetching(monoCreateFetcher+ a DataSource for lists/tables); for one row, reuse a source you already hold viaload({ take: 1 }). Don't: add an external HTTP lib; build a second fetcher (monoOdataFetch/ anothermonoCreateFetcher) for a detail/related row you could read from a DataSource you already hold; or usedataSource.store().byKey()for a single record.
All data access goes through mono-utils/fetching. Never introduce an external HTTP library (axios, ofetch, ky, a raw fetch wrapper, β¦). The mono helpers already resolve the base URL, token / JWT, headers, and OData source from mono.config.ts (Rule 5) β bypassing them breaks auth and the shared config.
Two things to do when fetching enters the picture:
- New base URL β add it to
mono.config.tsfirst. If the user gives a base URL that isn't already a named entry underfetching.api, add it there, then consume it by name withconfigBaseUrl: '<entry>'. That keeps URLs centralized and shared so both Host and Remote can use them β don't hardcode a one-offbaseUrlfor something reusable. - New page β ask if it should be deployable. When you plan a new page (Rule 15), ask the user: should this feature be visible to the other Host/Remote β i.e. registered in
mono.config.tsmenuso it shows in the nav β or is it still in development and should stay out of the menu for now?
monoCreateFetcher + a DataSource is the standard β
monoCreateFetcher is the all-in-one fetch solution β make it your default for list and table data. It returns a reactive DataSource, and that DataSource is how you should hold and re-query server data. Why:
- One call gives a live handle, not a dead snapshot β it already knows its base URL, token, and OData source from
mono.config.ts. - You fetch once, keep the DataSource in a
ref, and re-query through that same source β filter, search, sort, changeselect. You never build a second fetcher.
ts
// (per Rule 15 this lives in the store)
const usersSource = ref<any>(null)
onMounted(async () => {
const { dataSource } = await monoCreateFetcher({
configBaseUrl: 'myOdata',
url: '/Users',
}).response({ options: { select: ['Id', 'Name'], paginate: true, pageSize: 20 } })
usersSource.value = dataSource
})
// later β need a different slice? reuse the same source, no new fetcher:
await usersSource.value.store().load({ filter: ["contains(Name,'jo')"], select: ['Id', 'Name'] })
// need one row (e.g. a DETAIL view) while the source is bound to a grid/select?
// query its STORE so the bound list isn't disturbed β and do NOT build a 2nd fetcher:
const [detail] = await usersSource.value.store().load({
filter: ['Id', '=', id], expand: ['Role'], select: ['Id', 'Name', 'Role'], take: 1,
})
// the source is NOT bound to a live component? dataSource.load() is fine too:
const [one] = await usersSource.value.load({ filter: ['Id', '=', id], take: 1 })Single record β reuse the source, never a 2nd fetcher or byKey()
Already holding a DataSource (a list/table you fetched)? To read one row from it β a detail view, a related record β reuse that same source; don't reach for a fresh monoOdataFetch or another monoCreateFetcher. That's the whole point of the live handle: one source, re-queried.
- Source is bound to a live component (grid /
mono-select): query its store βsource.store().load({ filter: ['Id','=',id], take: 1, expand, select }). This runs a one-off query without disturbing the bound list's paging/filter/sort. - Source is not bound to anything:
source.load({ filter: ['Id','=',id], take: 1 })is fine too. - Never
dataSource.value.store().byKey(id)β once the source is bound,byKeycaches against the already-loaded data and can return a stale row (or nothing).
See DataSource.
Bind that DataSource straight to mono-select, mono-tag-input, or a monoDataGrid table β see DataSource.
One DataSource per purpose
A DataSource is a single live, shared instance. If you bind the same DataSource to two components β say a mono-select and a mono-tag-input β they share its state, so filtering, sorting, or paging from one also changes the other. Create a separate DataSource (its own monoCreateFetcher call) for each component / purpose. Reuse the same source only when you genuinely want the two views kept in sync.
OData: select only what you need β
With OData you shape the response, so select only the fields the screen actually uses. Before adding a field, find the reason it's needed; if there isn't one, ask the user. Example: a table renders Id and Name β does it really need Description? If nothing displays or filters on it, leave it out (smaller payload, faster load). Work out the reason first, then ask.
What's in mono-utils/fetching β
| Export | What it does |
|---|---|
monoFetch | Plain REST fetch. Resolves base URL (configBaseUrl or baseUrl), token, and headers. |
monoOdataFetch | OData fetch β shape the result via options (select, filter, sort, paginateβ¦). Returns { data, dataSource, statusCode, error }. |
monoOdataFetchUnique | Like monoOdataFetch, but collapses concurrent identical requests (unique). |
monoCreateFetcher | All-in-one builder β a reactive OData DataSource. The default for list / table data. |
monoStaticDataSource | Wrap a static array as an OData-style DataSource. |
monoTryCatchDatasource | Safe load wrapper around a DataSource (handles errors / notifications). |
monoLoadChuckStores | Load a store in batched chunks. |
monoConfigureFetching / monoResetFetchingConfig | Set / reset runtime fetch options (notif, token, base-URL overrides). |
monoFetchingRuntime | Inspect the resolved runtime (base URLs, source, token) for a configBaseUrl. |
monoRestBaseUrl / monoOdataBaseUrl / monoOdataSource / monoRequestToken | Low-level resolvers (REST/OData base URL, OData source ctors, request token). Rarely needed directly. |
See Data Fetching for full call examples and DataSource for binding a DataSource to components.
12. OData URL β generate typed classes with odata2ts β
When: the user gives you an OData URL, or you consume an OData entity. Do: generate classes with
odata2ts, then map them withEswOdataMapTypesinsrc/types/odata.d.ts. Don't: hand-write the entity interfaces, or edit the generatedsrc/odata/<Key>/output.
When the user hands you an OData URL (or asks you to consume an OData entity), never hand-write the entity interfaces. Generate them from the service's $metadata with odata2ts, then map the output to plain types. This keeps the shapes correct and in sync with the backend.
The flow, in short:
- Configure the service in
odata2ts.config.tsβ each entry's key is unique and becomes the folder name undersrc/odata/<Key>. One config can hold many OData sources (one key/folder each). - Generate with
pnpm odata:genβ it writes the Q-objects, models, and services intosrc/odata/<Key>/. That output is generated β never hand-edit it (Rule 4). - Map to usable types. The generated output is raw query-object classes, not plain shapes. In
src/types/odata.d.ts, wrap each withEswOdataMapTypes<typeof Q...>(fromesw-utils), importing the Q-object through the app's own resolve alias β@mono-vue/odata/...in a Remote,@mono-host/odata/...in the Host (Rule 2). - Use the mapped types across the app β in store state, fetch generics, and table columns. They're safe and shared.
If the OData base URL is new, it's also an env var (MONO_β¦, Rule 7) and a fetching.api entry (Rules 5 / 11).
See OData Types for the full step-by-step with real examples.
13. UI β reach for mono-helper/ui first β
When: building any UI. Do: go top-down β
<mono-*>component β its.mono-*CSS β UnoCSS β native CSS insrc/assets/. Don't: write custom markup/CSS when amono-*component already covers it.
Build UI with the shared library before writing anything custom. Follow this priority top-to-bottom, and only drop to the next level when the current one genuinely can't do the job:
- mono-ui Lit component β the web components from
mono-helper/ui/*(<mono-button>,<mono-input>,<mono-select>, β¦). Import the subpath (import 'mono-helper/ui/button') and use the tag. This is the default for anything that has a matching component. - mono-ui native (CSS only) β when you must hand-build the markup, reuse the component's
.mono-*CSS classes and assemble the element yourself. Same look, full control over the DOM. - UnoCSS utilities β no suitable mono-ui for a very custom layout/style? Use UnoCSS utility classes.
- Native CSS (last resort) β UnoCSS still can't express it? Write plain CSS and store it in
src/assets/in its own, specifically-named file (e.g.src/assets/brand-report.css), then import it only into the page that needs it. Don't scatter ad-hoc global<style>or dump rules into shared files.
See Mono-UI β Getting started for install and usage, and the per-component pages for the available tags and their props.
14. External utilities β reach for esw-utils helpers first β
When: you need validation, notifications, list add/update/remove, OData filters, Excel export, or JSON parsing. Do: use the auto-imported helper composable (
useHelper()), which spreadsesw-utils'useUtils(). Don't: hand-roll these; and don't useesw-utilsfor fetching (that's Rule 11).
Before you hand-roll form validation, notifications, list add/update/remove, OData filter strings, Excel export, or JSON parsing, stop β there's almost certainly a ready-made, battle-tested helper for it in esw-utils. Don't reinvent these; the shared helpers handle the awkward edge cases (nested schema errors, @odata.* metadata cleanup, double-wrapped JSON, rowspan Excel layouts) you'd otherwise rediscover.
These helpers cover, broadly:
- Validation (Yup) β validate a whole form or a single field, and clear errors.
- Notifications β one unified
notif()for success / info / warning / error / promise. - Data manipulation & filtering β upsert/remove rows in arrays or DataSources, build OData
infilters, stringify DevExtreme filters to$filter. - DevExtreme UI β lookup display/caching, custom SelectBox values, numeric guards, auto-numbering columns.
- Excel export β basic and advanced (rowspan, computed columns, totals).
- JSON & misc β safe parse, JSON / date type guards.
How to use them β
They're already wired into the project's helper composable at src/composables/use-helper.ts (a Host keeps its shared one under src/composables/shared/use-helper.ts). That composable spreads in esw-utils's useUtils(), and it's auto-imported (Rule 10 / Rule 1) β so you destructure what you need with no import line:
ts
// the composable exported from src/composables/use-helper.ts
const { validateAllSchema, notif, replacerData } = useHelper()If you ever need the source directly instead of the project composable, import it:
ts
import { useUtils } from 'esw-utils'
const { dxFilterToString } = useUtils()Fetching stays on mono-utils/fetching
esw-utils also ships fetching / datasource helpers, but data access still goes through mono-utils/fetching (Rule 11) β that's the standard path here. Use esw-utils for the value-add helpers above, not for fetching.
See Useful Utils for the full list of helpers, signatures, and what each one does.
15. Building a new feature β separate the code β
When: building a feature or page β the synthesis step that uses Rules 1β14. Do: split into layers β thin page, components, Pinia store (logic+fetching), composable helpers,
.d.tstypes. Don't: dump everything in one file, or put business logic / fetching in the page.
This is the last rule on purpose: by here you understand the machine, the conventions, and the capabilities. Building a feature just composes them.
When asked to build a feature or page (say a Brand / Master screen), never dump everything into one file. Split it across these layers so logic stays reusable and the page stays readable.
1. Ask for the page name first. Before creating anything, ask the user what the page/route should be named (e.g. brand). Don't assume a name.
2. Create the page as a thin entry at src/pages/<name>.vue. The page is presentational: markup plus wiring to the store. No business logic, no fetching, no heavy computation inline β it only renders UI and reads from / calls the store.
3. Extract components when the page grows. If a page gets large (~500 lines) or contains markup that repeats or could be reused, pull those chunks into their own focused components under src/components/<name>/. Keep each file small and single-purpose.
4. Put all logic in a Pinia store at src/stores/use-<name>.ts. Everything that isn't markup lives here and is globally reusable: reactive state (ref / reactive), computed, watchers, plain functions, and fetching. Pages and components only read from or call into the store. Use the project's fetch helpers from Data Fetching β monoFetch, monoFetchOdata, monoCreateFetcher.
5. Move generic helpers into a composable at src/composables/use-<name>-utils.ts. Pure, reusable functions that might be needed again later go here. These are usually consumed only by the store (use-<name>.ts).
6. Always TypeScript, with types in .d.ts. Every file is typed. Whenever the store holds typed reactive data β e.g. const data = ref<Brand[]>() β declare that type in src/types/<name>.d.ts and import it. Don't inline complex shapes ad-hoc.
Layout β
src/
βββ pages/
β βββ brand.vue # thin page β UI + store wiring only
βββ components/
β βββ brand/ # extracted, reusable pieces (when the page grows)
β βββ BrandTable.vue
βββ stores/
β βββ use-brand.ts # ALL logic: state, computed, fetching, functions
βββ composables/
β βββ use-brand-utils.ts # generic helpers, usually used by the store
βββ types/
βββ brand.d.ts # types for the store's reactive dataEach layer, in short β
src/types/brand.d.ts β the shape, declared once and shared.
ts
export interface Brand {
id: string
name: string
active: boolean
}src/stores/use-brand.ts β all state, fetching and logic; globally reusable.
ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { monoFetch } from 'mono-utils/fetching'
import type { Brand } from '@/types/brand'
import { sortByName } from '@/composables/use-brand-utils'
export const useBrand = defineStore('brand', () => {
const data = ref<Brand[]>([])
const loading = ref(false)
const activeBrands = computed(() => data.value.filter((b) => b.active))
async function load() {
loading.value = true
const res = await monoFetch<Brand[]>('/brands', { configBaseUrl: 'MyApi', method: 'GET' })
data.value = sortByName(res.data ?? [])
loading.value = false
}
return { data, loading, activeBrands, load }
})src/composables/use-brand-utils.ts β generic, reusable helpers.
ts
import type { Brand } from '@/types/brand'
export function sortByName(rows: Brand[]): Brand[] {
return [...rows].sort((a, b) => a.name.localeCompare(b.name))
}src/pages/brand.vue β thin: read the store, render UI.
vue
<script setup lang="ts">
import { onMounted } from 'vue'
import { useBrand } from '@/stores/use-brand'
const brand = useBrand()
onMounted(() => brand.load())
</script>
<template>
<section>
<mono-input label="Search" />
<!-- when this grows, move the table into components/brand/BrandTable.vue -->
<ul>
<li v-for="b in brand.activeBrands" :key="b.id">{{ b.name }}</li>
</ul>
</section>
</template>The takeaway: pages render, stores think, composables provide reusable helpers, and types live in .d.ts. There's no rigid flow beyond that β build what the feature needs, but keep it in these layers.
Before you finish β
A quick self-check against the always-rules before you call a change done:
- [ ] All data access goes through
mono-utils/fetchingβ no external HTTP lib (Rule 11). - [ ] A detail / single-row read reuses an existing DataSource (
source.store().load({ take: 1 })when it's bound to a component) instead of a second fetcher; neverstore().byKey()(Rule 11). - [ ] Every new store has a unique export name and
defineStorekey (app-suffixed) (Rule 8). - [ ] Nothing under
.apps/or any generated file (*.d.ts,src/odata/DTO/) was hand-edited (Rules 3, 4). - [ ] OData types are generated + mapped with
EswOdataMapTypes, not hand-written (Rule 12). - [ ] Variables/functions use consistent leading prefixes and handlers take one typed object β
openModal({ item }: { item: T })(Rule 6). - [ ] Typed data has its shape in a
.d.ts; static data lives insrc/datas(Rules 15, 9). - [ ] Cross-app /
datasΒ·typesΒ·odataimports use the@app-namealias (Rule 2). - [ ] UI starts from
mono-helper/ui; secrets live in env with the right prefix (Rules 13, 7). - [ ] You paused to ask before touching infra (
vite.config.ts, aliases,mono.config.tsextends/apps).
Host template β
When: you're working in the Host. Do: own the shared shell β
src/pages(Login/Logout),src/layouts(Sidebar/Navbar), Middleware, and the theme (Mono/Material + color). Don't: bury a Remote's module-specific logic in the Host.
The Host owns the shell every Remote reuses β see Getting Started. All Common Rules above apply here too.
- Owns: Login / Logout pages, layouts (Sidebar, Navbar), Middleware β the generic pieces a Remote should never rebuild.
- Owns the theme β and is the only app that does. The Host is the single place that imports the theme CSS (
mono-helper/index.css, plusmono-helper/ui/theme/mui.cssonly if Material is used) and sets the active theme (Mono / Material) and theme color via the theming API (applyTheme/applyFlavor). Theme/color apply as classes on a root element (<html>/<body>), so they cascade into every Remote automatically β a Remote inherits the Host's theme without importing or applying anything. See Theme. - Exposes to Remotes via the three
vite.config.tslinks (Rule 1) andmono.config.ts(Rule 5): its shared pages/components/stores and the merged cookie/JWT/API/menu config. - Keep out of the Host: feature logic that belongs to one Remote's module β build that in the Remote.
Starter repo: https://github.com/EJI-ICT/mono-vue-host
Remote template β
When: you're working in a Remote. Do: add your own modules under
src/, and register pages inmono.config.tsmenuto appear in the Host nav. Don't: re-create Login / Logout / layouts / Middleware, or manage the theme β the Host already provides them.
A Remote only adds its own modules and consumes the Host's shell β see Getting Started. All Common Rules above apply here too.
- Adds: its feature pages, components, stores under its own
src/(auto-imported per Rule 1). - Surfaces in the Host: add a
menuentry inmono.config.ts(Rule 5) so the module shows in the Host's navigation; ask the user whether a new page should be deployable yet (Rule 11). - Don't rebuild the shell: Login, Logout, Sidebar, Navbar, Middleware and layouts come from the Host β just use them.
- Don't manage the theme. Don't import the theme CSS (
mono-helper/index.css,mono-helper/ui/theme/mui.css) and don't callapplyTheme/applyFlavor. The Host owns and applies the theme globally; a Remote just uses themono-*components and inherits the active theme/color. See Theme.
Starter repo: https://github.com/EJI-ICT/mono-vue-remote