The frontend presentation layer consumes data from the backend and handles user interface concerns separately from business logic. Templates and views render shaped data, capture user actions, and trigger backend operations through the API the controllers expose. Business rules stay on the backend, so a change to the UI does not require changes to how the application enforces those rules.
Layers should depend on each other in one direction: the presentation layer depends on business logic, and business logic depends on data access. In a well-structured layered system, the reverse direction stays empty, so a service does not import from a controller and a data access function does not call into a service. This one-directional flow keeps the layered shape of the system stable and predictable as the codebase grows.
// Layers flow DOWN only: Presentation → Business → Data// Data: pure storageconst orders = [];const data_save = order => { orders.push(order); return order; };// Business: rules only, calls ↓ dataconst business_createOrder = (custId, items) => {const total = items.reduce((s, i) => s + i.price, 0);const order = { id: Date.now().toString(), custId, items, total };return data_save(order);};// Presentation: HTTP only, calls ↓ businessconst presentation_createOrder = (req, res) => {const order = business_createOrder(req.body.custId, req.body.items);res.status(201).json(order);};
Tight coupling between layers makes code difficult to test and modify, while loose coupling promotes flexibility. Coupling tightens when a layer reaches into another layer’s internals, instantiates concrete classes from below, or relies on implementation details that should be hidden. Loose coupling between layers usually means each layer talks to the next through a small, well-defined contract.
// TIGHT coupling: controller knows DB detailsasync function badController(req, db) {return db.query('SELECT * FROM orders WHERE id = ?', [req.params.id]);// Controller breaks if DB schema changes}// LOOSE coupling: small contracts between layersasync function goodController(req, orderService) {return orderService.getOrder(req.params.id); // Just needs "getOrder()"}async function orderService_getOrder(id, orderRepo) {return orderRepo.findById(id); // Repo hidden behind contract}
Dependency inversion at the architectural level means that high-level layers depend on abstractions rather than concrete implementations of the layers below them. A service depends on the shape of the data access functions it calls (e.g., a save function with a known signature) rather than on which specific module provides them. The same thinking behind DIP at the class level extends to whole layers, whether the contract is expressed as a class-style interface or as a function shape.
// Contract: any orderData module must expose .save(customerId, items)function createOrder(customerId, items, orderData) {// Business trusts orderData.save() exists — array, Mongo, SQL? Doesn't carereturn orderData.save(customerId, items);}// === USAGE: Inject different impls ===// In-memory (demo)import { save as memorySave } from './data-memory.js';createOrder('cust1', items, { save: memorySave });// Production Mongoimport { save as mongoSave } from './data-mongo.js';createOrder('cust1', items, { save: mongoSave });
Dependency injection is a technique for implementing inversion of control by providing dependencies from outside rather than creating them internally. A service function accepts the collaborators it needs (a save function, a logger, a clock) through its parameters rather than importing them directly. The function no longer chooses where its collaborators come from, which makes them easy to swap in tests and at runtime. Class-based codebases typically express the same idea through constructor injection.
// Service receives data layer via parameterfunction createOrder(customerId, items, save) {const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);const order = { id: Date.now().toString(), customerId, items, total };return save(order); // Caller controls what 'save' is}// Controller injects business + data implementationfunction presentation_createOrder(req, res) {const { customerId, items } = req.body;const order = createOrder(customerId, items, orderData.save);res.status(201).json(order);}// Data layer provides concrete implementationconst orderData = {save: (order) => { orders.push(order); return order; }};
AI agents can evaluate layer boundaries, identify coupling issues, and suggest architectural improvements when given a codebase to review. Asking an AI agent to trace which layer imports from which, or to flag where a controller seems to know about the database, surfaces problems that are hard to spot from inside a single file. The agent does not replace the architectural decision; it accelerates the analysis that informs it.
Help me analyze this codebase:- What layers exist?- How do they communicate?- Are any dependencies pointing in the wrong direction?- Where might coupling or responsibility drift be happening?
Backend services should return raw domain data and templates should focus on rendering, which leaves presentation-specific transformations (formatting currency, parsing timestamps, computing display labels) without a natural home in either layer. Patterns like MVC, MVVM, and MVP each address this boundary, with MVVM and MVP introducing a dedicated transformation step (the ViewModel and Presenter, respectively). One concrete example of that idea is a presenter function: it takes raw service data and returns a view-ready object, sometimes called a view model, that the template can render directly.
function presentOrder(order) {return {customerId: order.customerId,totalDisplay: formatCurrency(order.total),statusDisplay: capitalize(order.status),placedOnDisplay: formatDate(order.createdAt),};}
In a full-stack design, three backend layers (presentation, business logic, and data access) sit beneath a frontend presentation layer that handles the user interface, each with a distinct responsibility. The boundaries between layers are what contain change: as long as the contract a layer exposes to its neighbors holds, internal shifts inside that layer stay there. A swap of database technology lives inside the data access layer, a UI redesign lives in the frontend, and a new business rule lives in a service. That containment is the practical payoff: each layer can be reasoned about, tested, and modified without holding the rest in mind.
The presentation layer handles incoming requests, formats responses, and delegates the actual work to the business logic layer. Controllers parse and validate the request, hand off to a service, and shape what the service returns into a response. A controller that coordinates without making application decisions keeps request and response concerns isolated from business logic.
async function createOrder(req, res) {const { customerId, items } = req.body;// Delegation to the business logic layerconst order = await orderService.createOrder(customerId, items);res.status(201).json(order);}
The business logic layer contains application workflows and business rules, independent of how data is stored. Services orchestrate operations, enforce rules that go beyond simple input validation, and call into the data access layer when persistence is needed. Keeping business logic isolated from storage details means the rules stay correct regardless of the database technology beneath them.
async function createOrder(customerId, items) {const customer = await customerData.findById(customerId);const discount = calculateDiscount(customer.tier, items);const total = applyDiscount(items, discount);return orderData.save(customerId, items, total);}
The data access layer abstracts database operations behind a small, well-defined surface so that persistence concerns stay isolated from business logic. CRUD operations, query construction, and connection handling live here, returning data in the shapes the business layer expects. Swapping SQL for NoSQL, or in-memory storage for a real database, becomes a change confined to this layer. The layer can be structured as a class with methods or as a collection of module-level functions; the boundary it draws is the same in either form.
const orders = []; // in-memory storage owned by this layerfunction save(order) {orders.push(order);return order;}function findById(id) {for (const order of orders) {if (order.id === id) return order;}return null;}