Lua Academy · Lesson

Module Patterns and Best Practices

Use local M = {} pattern and expose public API cleanly.

Lesson 4 of 412 steps

Module Patterns and Best Practices is a free Lua Academy lesson on CoddyKit. This is lesson 4 of 4. Any 3 lessons of this learning path are free to read in full — after that, CoddyKit PRO unlocks every lesson, plus hands-on practice with a built-in code editor and a 24/7 AI tutor. It is part of the Lua Academy learning path, and your progress syncs across the web and the CoddyKit app. The Lua Academy course includes 4 lessons in total.

The M = {} Pattern

The universal Lua module pattern: declare a local table, populate it, return it. All public symbols go in M; all private helpers are plain locals. This is clear, minimal, and works everywhere.

-- The canonical module pattern
local M = {}

-- Private helper (not exported)
local function validate(x)
  return type(x) == "number" and x >= 0
end

-- Public API
function M.sqrt(x)
  assert(validate(x), "expected non-negative number")
  return math.sqrt(x)
end

M.PI = math.pi

return M

Self-Referential Module

Inside a module, functions can call other module functions either by name (M.foo()) or as locals. Using locals is slightly faster; using M.foo() allows users to override M.foo and have the internal call use the override (monkey patching).

local M = {}

-- Option A: use M.foo inside (allows override)
function M.double(n) return M.multiply(n, 2) end
function M.multiply(a, b) return a * b end

-- Option B: use local function (faster, no override)
local function mul(a, b) return a * b end
function M.triple(n) return mul(n, 3) end

return M

Singleton Pattern

A module can act as a singleton: it has mutable internal state shared by all callers. Since require caches the module, all require("mod") calls get the same object with the same state.

-- config.lua (singleton)
local M = {}
local _config = {env="dev", logLevel="info"}

function M.set(key, val)
  _config[key] = val
end

function M.get(key)
  return _config[key]
end

function M.load(t)
  for k,v in pairs(t) do _config[k]=v end
end

return M

-- All callers share the same config:

Module as Namespace

Use a module purely as a namespace to avoid polluting the global table. Group related constants and utilities under one name, like a package in other languages.

-- constants.lua
local M = {
  HTTP = {
    OK=200, CREATED=201, NO_CONTENT=204,
    BAD_REQUEST=400, UNAUTHORIZED=401,
    NOT_FOUND=404, SERVER_ERROR=500,
  },
  COLORS = {RED="#FF0000", GREEN="#00FF00", BLUE="#0000FF"},
  MAX_RETRIES = 3,
  TIMEOUT_SEC = 30,
}
return M

-- local C = require("constants")
-- if status == C.HTTP.NOT_FOUND then ...

Factory Module

A module that exports a factory function instead of a plain table. The factory creates and returns new instances with their own private state. This is the class pattern at the module level.

-- logger.lua
local M = {}

function M.new(name, level)
  level = level or "info"
  local levels = {debug=1,info=2,warn=3,error=4}
  local self = {}
  
  function self.log(msgLevel, msg)
    if levels[msgLevel] >= levels[level] then
      print(string.format("[%s][%s] %s", name, msgLevel:upper(), msg))
    end
  end
  
  function self.info(msg)  self.log("info",  msg) end
  function self.warn(msg)  self.log("warn",  msg) end
  function self.error(msg) self.log("error", msg) end
  
  return self
end

return M

Module Init Function

Some modules require configuration before use. Provide a M.init(config) function that stores config in the module's private state. This enables dependency injection and testability.

-- db.lua
local M = {}
local pool = nil

function M.init(config)
  pool = {
    host = config.host or "localhost",
    port = config.port or 5432,
    connections = {},
  }
  print("DB initialized:", pool.host, pool.port)
end

function M.query(sql)
  assert(pool, "call db.init() first")
  -- ... execute query
  return {}
end

return M

Immutable Module

Prevent users from modifying the module API accidentally by using __newindex to block all writes. This is especially useful for library modules where accidental monkey-patching could break things.

local function freeze(t)
  return setmetatable({}, {
    __index = t,
    __newindex = function(_, k, _)
      error("module is read-only, cannot set: " .. tostring(k), 2)
    end
  })
end

local M = {}
function M.add(a, b) return a + b end
function M.sub(a, b) return a - b end

return freeze(M)

Documenting with LDoc

A common convention for documenting Lua modules is LDoc-style comments with --- prefix. While not enforced by the language, these comments enable documentation generation tools to produce API docs automatically.

--- A utility module for string operations.
-- @module stringutils
local M = {}

--- Trim leading and trailing whitespace.
-- @param s string The input string.
-- @return string The trimmed string.
function M.trim(s)
  return s:match("^%s*(.-)%s*$")
end

--- Count occurrences of a substring.
-- @param str string The string to search.
-- @param sub string The substring to count.
-- @return number Count of occurrences.
function M.count(str, sub)
  local _, n = str:gsub(sub, "")
  return n
end

return M

Testing Modules

Test a module by requiring it and exercising each function. Use a simple test runner or busted (the Lua testing framework). Keep tests in a separate file that mirrors the module's path.

-- test/test_stringutils.lua
local su = require("stringutils")

local function test(name, fn)
  local ok, err = pcall(fn)
  if ok then print("[PASS] " .. name)
  else   print("[FAIL] " .. name .. ": " .. err)
  end
end

test("trim removes spaces", function()
  assert(su.trim("  hello  ") == "hello")
end)

test("trim empty string", function()
  assert(su.trim("") == "")
end)

test("count occurrences", function()
  assert(su.count("banana", "a") == 3)
end)

Composing Modules

Complex systems compose multiple modules. A main entry point requires and wires together sub-modules. This separation of concerns keeps each module focused and independently testable.

-- app.lua (main entry point)
local config = require("config")
local db     = require("db")
local server = require("server")

-- Configure from environment
config.load({
  dbHost = os.getenv("DB_HOST") or "localhost",
  port   = tonumber(os.getenv("PORT")) or 8080,
})

-- Wire modules together
db.init({host=config.get("dbHost"), port=5432})
server.init({port=config.get("port"), db=db})
server.start()

Quick Check

What is the main purpose of the local M = {} ... return M module pattern?

Recap: Module Best Practices

Summary:

  • Always local M = {} ... return M
  • Private = file-level locals; Public = M fields
  • Singleton: require caches the module instance
  • Use factory functions for per-instance state
  • Freeze modules with __newindex to prevent modification
  • Test in separate files; document with --- comments
Free to start

Learn Lua with an AI tutor — free

Write and run real code in your browser, get instant help from a 24/7 AI tutor, and pick up where you left off on the web or in the app.

Courses
40
Lessons
159

Frequently Asked Questions

Is the “Module Patterns and Best Practices” lesson free?

Yes — any 3 lessons of the Lua Academy learning path, including “Module Patterns and Best Practices”, are free to read in full here on the web. After that, CoddyKit PRO unlocks every lesson, plus interactive practice with a built-in code editor and a 24/7 AI tutor. The Lua Academy course includes 4 lessons in total.

What will I learn in “Module Patterns and Best Practices”?

Use local M = {} pattern and expose public API cleanly. You practise Lua Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start Lua Academy?

No prior experience is required. Lua Academy on CoddyKit is structured for beginners through advanced learners, so you can start here or from the beginning and move at your own pace. This is lesson 4 of 4.

How long does the “Module Patterns and Best Practices” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this Lua Academy lesson?

Yes. Every Lua Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. The require Function
  2. Writing a Module File
  3. package.path and package.cpath
  4. Module Patterns and Best Practices
← Back to Lua Academy