Lua tables already represent most application data well. Metatables are useful when a table needs a controlled protocol: shared method lookup, diagnostic formatting, an operator with an unambiguous meaning, or a proxy boundary. They are less useful when they are used to disguise Lua as a class-based language.

The examples use Lua 5.4.7 and rely on stable Lua 5.4 semantics.

Share behavior with __index

Consider an account with a name and balance. Mutations must preserve a few simple rules. Instances hold data, and the Account table holds behavior shared by all instances.

local Account = {}
Account.__index = Account

function Account.new(name, opening_balance)
  assert(type(name) == "string" and name ~= "", "name is required")
  assert(type(opening_balance) == "number", "balance must be a number")
  assert(opening_balance >= 0, "balance cannot be negative")

  return setmetatable({
    name = name,
    balance = opening_balance,
  }, Account)
end

function Account:deposit(amount)
  assert(type(amount) == "number" and amount > 0, "amount must be positive")
  self.balance = self.balance + amount
end

function Account:withdraw(amount)
  assert(type(amount) == "number" and amount > 0, "amount must be positive")
  if amount > self.balance then
    return nil, "insufficient funds"
  end

  self.balance = self.balance - amount
  return true
end

function Account:__tostring()
  return string.format("Account(%s, %.2f)", self.name, self.balance)
end

local account = Account.new("Travel", 200)
account:deposit(50)
assert(account:withdraw(80))
print(account) -- Account(Travel, 170.00)

When Lua cannot find deposit in the instance, __index tells it to continue the lookup in Account. The colon syntax passes the receiver as the first self argument; it does not introduce a hidden class system.

The constructor validates the initial invariant before attaching the metatable. Callers cannot accidentally create an account with a negative opening balance through this public function.

Make failure behavior consistent

The example uses assert for programmer errors, such as passing a string where an amount is required. It returns nil, message for insufficient funds, an expected domain outcome that a caller may handle. A larger system might return a structured error value instead. The exact choice matters less than consistency at one API layer.

An interface becomes difficult to use when one method raises an error, another returns a boolean, and a third silently corrects invalid input. Write down which failures indicate a violated programming contract and which are ordinary business outcomes. Test both paths at the module boundary so callers can handle expected failures without parsing exception text.

Be cautious with __newindex as a universal validation hook. It runs when normal assignment does not find an existing key, so behavior can change after a key is present. A proxy table can deliberately use that rule, but a domain object is often clearer with explicit mutation methods. Another option is to store internal data in a separate table captured by the module and expose only controlled operations.

Add metamethods only when the meaning is obvious

__tostring is useful here because it improves logs and debugging without changing the account’s domain behavior. Operators such as __add, __eq, and __len need a stronger justification. What should adding two accounts produce: a number, a combined account, or an error? If the answer needs documentation before it can be guessed, a named function will usually be clearer.

Equality also deserves care. Identity, matching account numbers, and complete field equality are different concepts. Encoding one of them as == may hide a business decision that should be visible in a function name.

Avoid deep __index chains that imitate several levels of inheritance. Lookup and override behavior quickly become difficult to trace. Composing a few small tables or passing functions as dependencies normally fits Lua’s model better.

Serialization should cross an explicit data boundary. Do not iterate over a behavior-rich instance and assume that decoding the resulting table recreates the same object. Provide a to_table function that emits only fields such as name and balance, then restore through Account.new or a dedicated validated loader. Old-data migration, missing fields, and invalid values then pass through one reviewable entry point, and functions or internal caches never leak into a configuration file.

If the module evolves, version that plain representation rather than the metatable layout. Consumers should depend on the documented operations and serialized data, not on where methods happen to be found.

Protect the module boundary

If consumers should not replace the metatable, return instances without exporting the Account table, or protect metatable access with the documented __metatable field. This is not a security sandbox—code running in the same process may have other powers—but it can prevent accidental coupling to implementation details.

Tests should focus on invariants: negative opening balances fail, deposits are positive, an overdraft leaves the balance unchanged, a successful withdrawal happens exactly once, and diagnostic output does not reveal sensitive data. Plain assert statements executed by the Lua interpreter are enough to test this small module.

Metatables are most valuable when they let ordinary tables implement a stable protocol at a module boundary. Keep the data understandable, mutations explicit, and errors predictable. The result is a lightweight domain model that feels natural in Lua instead of a complicated class system rebuilt on top of it.