Feature modules: structure, ownership, and data flow
Each feature module in Real Estate Template owns its full vertical slice. Learn how properties, agents, and developments are structured and how data flows from service to component.
Use this file to discover all available pages before exploring further.
A feature module in Real Estate Template is a self-contained directory that owns everything needed to deliver one business capability — its domain types, runtime validation schemas, static data, service logic, Pinia store, composables, constants, and Vue components all live together under features/<name>/. Nothing outside the feature needs to know how it works internally; pages and other features interact with it only through the service and the component props that service data flows into.
The features/properties/ directory is the most complete feature in the template and serves as the canonical example. Every sub-folder has a specific role:
Not every feature uses every folder. Only create folders when there is code to put in them. The leads/ and search/ features, for example, are stubs today — they contain only a .gitkeep placeholder and will gain components, services, and types as the feature is built out.
The TypeScript interfaces are the authoritative shape for the domain. They are backend-compatible by design so the static MVP data can be replaced by a real API response without touching any component.
// features/properties/types/property.types.tsimport type { MeasurementUnit } from '~/types/agency.types'export type PropertyOperationType = 'sale' | 'rent'export type PropertyType = | 'house' | 'apartment' | 'land' | 'commercial' | 'office'export type PropertyStatus = | 'available' | 'sold' | 'rented' | 'reserved' | 'hidden'export interface PropertyCoordinates { lat: number lng: number}export interface Property { id: string title: string slug: string description: string operationType: PropertyOperationType propertyType: PropertyType price: number currency: string location: string city: string state: string country: string bedrooms?: number bathrooms?: number parkingSpaces?: number sizeUnit?: MeasurementUnit constructionSize?: number landSize?: number images: string[] coverImage: string amenities: string[] developmentId?: string agentId?: string coordinates?: PropertyCoordinates status: PropertyStatus featured: boolean}
The Zod schema mirrors the TypeScript interfaces and acts as the runtime boundary. Data flowing into the application — today from the static file, tomorrow from a CMS or REST endpoint — is validated here before services or components ever see it.
The service is where all business logic lives. Components and pages never touch the data layer directly — they always go through propertiesService. The service interface is stable, so swapping the data source (static file → API) only requires changing the service implementation.
getAll / getBySlug
filter
getFeatured / getRelated
// features/properties/services/properties.service.tsimport type { Property } from '../types/property.types'import { sampleProperties } from '../data/properties'export const propertiesService = { /** All visible properties (excludes hidden ones). */ getAll(): Property[] { return sampleProperties.filter(property => property.status !== 'hidden') }, /** * Look up a single visible property by its slug. Returns `undefined` * when the property does not exist or is hidden, so callers can map * that to a proper 404. */ getBySlug(slug: string): Property | undefined { return sampleProperties.find( property => property.slug === slug && property.status !== 'hidden', ) },}
export const propertiesService = { /** * Filter the visible catalog by operation type, property type, and * location (accent-insensitive substring match), then sort. * Empty/undefined filter values are treated as "no constraint". */ filter(filters: PropertyFilters, sort: PropertySort = 'featured'): Property[] { const operation = normalize(filters.operation) const type = normalize(filters.type) const location = normalizeText(filters.location) const filtered = this.getAll().filter((property) => { if (operation && normalize(property.operationType) !== operation) return false if (type && normalize(property.propertyType) !== type) return false if (location) { const haystack = [property.location, property.city, property.state, property.country] .map(normalizeText) .join(' ') if (!haystack.includes(location)) return false } return true }) const sorted = [...filtered] switch (sort) { case 'price-asc': sorted.sort((a, b) => a.price - b.price || a.id.localeCompare(b.id)) break case 'price-desc': sorted.sort((a, b) => b.price - a.price || a.id.localeCompare(b.id)) break case 'featured': default: sorted.sort((a, b) => { if (a.featured !== b.featured) return a.featured ? -1 : 1 return a.id.localeCompare(b.id) }) } return sorted },}
export const propertiesService = { /** * Featured properties for homepage showcases. * @param limit Optional cap on the result count. */ getFeatured(limit?: number): Property[] { const featured = this.getAll().filter(property => property.featured) return typeof limit === 'number' ? featured.slice(0, limit) : featured }, /** * Weighted similarity score to find related listings for the detail page. * Scoring: +3 same propertyType | +2 same operationType * +2 same city | +1 same country (when city differs) * +2 same developmentId | +1 same agentId * +1 price within 30% * Falls back to getFeatured(limit) when no positive-score candidates exist. */ getRelated(current: Property, limit = 3): Property[] { // … see full source for scoring implementation },}
For the MVP, data/properties.ts is the raw data source. It exports sampleProperties, an array validated against propertyListSchema at module load time. In a later phase, the service’s getAll() and getBySlug() methods will call api/get-properties.ts (which uses $fetch) instead — zero changes to any component.
UI constants that enumerate valid domain values live in constants/. They wire TypeScript types to i18n keys and Nuxt Icon names so no component duplicates that mapping:
Feature components receive typed props and emit events. They never fetch their own data.
// features/properties/components/PropertyCard.vue (script setup)import type { Property } from '../types/property.types'defineProps<{ property: Property}>()
PropertyGrid.vue composes PropertyCard instances and receives the full list as a prop. PropertyGallery.vue receives the images and coverImage arrays. None of them call a service directly — the page calls the service and passes the result down.
URL query parameters arrive as string | string[]. The isPropertySort type guard narrows raw query values to the PropertySort union before they reach the service, preventing invalid sort values from propagating:
// features/properties/services/properties.service.tsexport type PropertySort = 'featured' | 'price-asc' | 'price-desc'const VALID_SORTS: readonly PropertySort[] = ['featured', 'price-asc', 'price-desc'] as constexport function isPropertySort(value: unknown): value is PropertySort { return typeof value === 'string' && (VALID_SORTS as readonly string[]).includes(value)}
agents/, developments/, home/, and search/ all follow the same vertical-slice structure. Each has its own types, schemas, service, and components. They do not share implementation details — they only share the convention.
agents/
AgentCard component. Static data in data/agents.ts. Types define Agent with id, name, role, bio, image, and optional phone, email, whatsapp, and specialties.
developments/
DevelopmentCard component. Static data in data/developments.ts. Types define Development with id, name, slug, status, location, and pricing fields. Properties link back via developmentId.
home/
Homepage-specific section components (HomeHero, HomeFeaturedProperties, HomeLocations, HomeCategories, HomeSearchBar, and more). No store — sections are composed from service calls at the page level.
Adding a new field to the Property model is a four-step process that never touches global code:
1
Update the TypeScript type
Add the new field to features/properties/types/property.types.ts. TypeScript will immediately flag every place that constructs a Property without the new field.
2
Update the Zod schema
Mirror the change in features/properties/schemas/property.schema.ts. The compile-time structural guard (_typeCheck) will fail to compile if the schema and the interface drift apart.
3
Update the data file
Add the field to the records in features/properties/data/properties.ts. The propertyListSchema.parse() call at module load will throw at runtime if any record is missing a required field.
4
Update the component prop usage
Display or use the new field in the relevant feature component. Because props are typed, the component’s defineProps<{ property: Property }>() now includes the field automatically — no other configuration required.
When the MVP data layer is replaced by a real API, only steps 3 and the api/ layer change. The types, schemas, and components remain identical — that is the point of keeping the service as the sole data access boundary.