Guide

MVC Pattern

RoExpress maps onto MVC cleanly: models own data, controllers own routes, the Bridge decouples them without shared imports.

Folder layout

ServerScriptService/
  Server/
    Models/
      PlayerModel.luau
    Controllers/
      ShopController.luau
    App.luau         ← creates App, wires controllers

PlayerModel.luau

local Players = game:GetService("Players")
local DS      = game:GetService("DataStoreService"):GetDataStore("v1")
local bridge  = RoExpress("Bridge")
local cache   = {}
local M       = {}

function M.Get(userId)     return cache[userId] end
function M.AddCoins(userId, amount)
    local d = cache[userId]; if not d then return end
    d.coins += amount
    bridge.Fire("player.coins.changed", { userId = userId, coins = d.coins })
end

Players.PlayerAdded:Connect(function(p)
    local ok, d = pcall(function() return DS:GetAsync(p.UserId) end)
    cache[p.UserId] = ok and d or { coins = 0, level = 1 }
    bridge.Fire("player.loaded", p)
end)
Players.PlayerRemoving:Connect(function(p)
    pcall(function() DS:SetAsync(p.UserId, cache[p.UserId]) end)
    cache[p.UserId] = nil
end)

return M

ShopController.luau

local PlayerModel = require(Models.PlayerModel)
local catalogue  = { sword = { price = 50 }, shield = { price = 75 } }
local M = {}

function M.Register(app)
    app:Get("shop/catalogue", function(P, _, req, res)
        res:Send(catalogue)
    end)

    app:Post("shop/buy", function(Player, _, req, res)
        local item = catalogue[req.data and req.data.id]
        if not item then res:Status(404):Error("Not found"); return end
        local d = PlayerModel.Get(Player.UserId)
        if d.coins < item.price then res:Status(400):Error("Insufficient"); return end
        PlayerModel.AddCoins(Player.UserId, -item.price)
        res:Send({ ok = true, remaining = PlayerModel.Get(Player.UserId).coins })
    end)
end

return M

App.luau | wire it together

local app            = RoExpress("App")
local ShopController = require(Controllers.ShopController)
ShopController.Register(app)

-- push data once model fires loaded
local bridge = RoExpress("Bridge")
bridge.Bind("player.loaded", function(player)
    app:Push(player, "player.data", PlayerModel.Get(player.UserId))
end)
Decoupling rule. Controllers never import each other | only models and Bridge. This means you can add a new controller without touching any existing file.

See also

App  ·  Bridge | decoupling event bus  ·  Server Push  ·  Player Data | flat version of this pattern  ·  Shop | flat version