The UmimaClean Stack, Part 2: VineJS and Validation as a Layer

Making validation composable and localised - a shared rule vocabulary, Indonesian messages registered once, a global transform that hands you Luxon dates, and the line between what a validator may know and what belongs in a service.

A 13-part series on the UmimaClean stack. 1 — AdonisJS · 2 — VineJS · 3 — Lucid · 4 — Inertia · 5 — Transformers · 6 — Transmit · 7 — Leaflet · 8 — Midtrans · 9 — Fonnte · 10 — ExcelJS · 11 — Frontend · 12 — Security · 13 — Testing

Validation is the layer everyone writes badly at least once. Rules get copied between forms and drift. Error messages get written inline in whichever language the app speaks. Business logic leaks in, and then the same rule exists in two places with two different answers.

VineJS is Adonis’s validator. This part is about the three things I did with it that stopped those failures, and the one boundary that made the whole layer make sense.

The Boundary First

Before any code, the rule that decides what goes where:

A validator may know about shape, type, format, and uniqueness. It may not know about order status, roles, or capacity.

“This must be a positive integer” is validation. “This address must belong to the customer who is booking” is not — it depends on who is asking, which is a question about the request’s identity rather than its shape.

So this is a validator’s job:

export const orderValidator = vine.create({
	addressId: vine.number().positive(),
	pickupDate: vine.date().after('today')
})

And this is a service’s:

const address = await Address.query().where('id', data.addressId).where('user_id', user.id).first()

if (!address) {
	throw new vineErrors.E_VALIDATION_ERROR([
		{ field: 'addressId', message: 'Alamat penjemputan tidak ditemukan.' }
	])
}

Note that the service throws the same error type. More on that below — it is what lets a business rule render as a form error without the validator having to know the rule.

A Shared Rule Vocabulary

The first thing that goes wrong at scale is the same field validated differently in two forms. A phone number is minLength(10) on signup and a bare string() on the address form, and now you have addresses with phone numbers the login page would reject.

app/validators/shared.ts is the vocabulary, and every form composes from it:

export const name = () =>
	vine
		.string()
		.trim()
		.minLength(1)
		.maxLength(50)
		.alpha({ allowSpaces: true, allowDashes: true, allowUnderscores: false })

export const phone = () =>
	vine
		.string()
		.trim()
		.regex(/^08[1-9]\d{8,10}$/)

export const password = () =>
	vine
		.string()
		.trim()
		.minLength(8)
		.maxLength(16)
		.regex(/^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,16}$/)

export const image = () =>
	vine.file({
		size: '5mb',
		extnames: ['png', 'jpg', 'jpeg']
	})

They are functions returning schemas, not shared schema instances. That matters: VineJS schemas are chainable builders, so a shared instance could be mutated by one caller adding .optional() and affect every other. Returning a fresh one each call makes them safe to extend locally.

The rules encode local knowledge rather than generic advice:

RuleConstraintWhy this shape
phone()/^08[1-9]\d{8,10}$/Indonesian mobile numbers. 08, never 0801, 10–12 digits total
name()1–50 chars, letters/spaces/dashesRejects digits and symbols in a human name
password()8–16 chars, letter and digit, alphanumerics onlyDeliberately not “one symbol required”
image()≤5 MB, png/jpg/jpegProof photos from phone cameras

That phone regex is doing real work. 08[1-9] rejects 080..., which is not a valid Indonesian mobile prefix, and the {8,10} tail allows the 10-to-12-digit range that actually exists. A generic mobile() rule would accept numbers this system cannot send WhatsApp to — which matters a lot when the phone number is the login identity and the only notification channel.

The password rule is worth defending, because it is less strict than the reflex. No symbol requirement, and a 16-character ceiling. The users are shop staff typing on phones between stops and customers who will otherwise write it on a sticky note. Requiring a symbol on a mobile keyboard buys a small amount of entropy at a real cost in adoption, and the rate limiting in Part 12 is doing more work against actual attacks than a character class would.

Composing Objects

Nested shapes compose the same way:

export const item = vine.object({
	brand: vine.string().trim(),
	model: vine.string().trim(),
	type: vine.string().trim(),
	size: vine.string().trim(),
	material: vine.string().trim(),
	condition: vine.string().trim(),
	note: note(),
	service: service(),
	additionalServices: vine.array(service()).optional()
})

An inspected item is the same object whether it arrives from a field inspection or from walk-in intake at the counter, so both validators embed item rather than describing it twice:

export const inspectionValidator = vine.create({
	photo: image(),
	items: vine.array(item)
})

And the type falls out of the schema rather than being declared alongside it:

export type ItemData = Infer<typeof item>

One source of truth for the shape. Add a field to item and the type updates, the validation updates, and any service destructuring it fails to compile until it is handled. This is the single biggest practical advantage of a schema-first validator over hand-written type definitions plus manual checks.

Indonesian Messages, Written Once

The app is entirely in Indonesian. Default VineJS messages are English, and the naive fix — passing a messages object to every validator — means the wording for required exists in fifteen files.

start/validator.ts is a preload, so it runs before any request:

export const validationMessages = {
	required: '{{ field }} wajib diisi',
	string: '{{ field }} harus berupa teks',
	minLength: '{{ field }} minimal {{ min }} karakter',
	maxLength: '{{ field }} maksimal {{ max }} karakter',
	confirmed: '{{ field }} tidak cocok',
	enum: '{{ field }} yang dipilih tidak valid',
	'phone.regex': '{{ field }} harus berupa nomor HP Indonesia yang valid',
	'password.regex': '{{ field }} harus berisi huruf serta angka',
	'array.minLength': '{{ field }} minimal berisi {{ min }} item',
	'date.after': '{{ field }} harus setelah {{ expectedValue }}'
	// ...every rule the app uses
}

Registered globally with a SimpleMessagesProvider. Two mechanisms in there are worth knowing.

Interpolation. {{ field }}, {{ min }}, {{ expectedValue }} are filled from the rule’s own arguments, so one message template covers every field that uses that rule.

Field-scoped overrides. 'phone.regex' beats 'regex' for the phone field specifically. Without it, a failed phone number produces the generic “format tidak valid” — technically true and useless. With it, the user is told what a valid Indonesian mobile number looks like. Same mechanism for password.regex, which is where you explain the letter-and-digit requirement rather than showing someone a regular expression.

Relabelling One Field

The shared name label is “Nama lengkap” — full name. Correct on a signup form, nonsense on a catalogue entry, where name is the name of a service.

The fix is to clone the messages and override one key, which is why validationMessages is exported rather than being a private constant:

// service_validator.ts borrows the shared vocabulary
// and relabels the one field that reads wrong

Small thing. It is also the difference between a localisation layer people actually use and one they route around by hardcoding a message inline.

The Global Date Transform

This one is small enough to miss and pays for itself constantly.

VineJS validates dates and hands back a JavaScript Date. Lucid models store and return Luxon DateTime. Left alone, every controller that touches a date converts between the two, and one of them eventually forgets.

start/validator.ts closes the gap globally:

/**
 * The transform below converts all VineJS date outputs from JavaScript
 * Date objects to Luxon DateTime instances, so that validated dates are
 * ready to use with Lucid models and other parts of the app that expect
 * Luxon DateTime.
 */

Which means this works straight out of a validator, with no conversion step anywhere:

const scheduledPickups = await Order.query().where(
	'pickup_date',
	data.pickupDate.toFormat('yyyy-MM-dd')
)

data.pickupDate is a DateTime, typed as one, because the transform is registered before anything validates. Boundary conversions belong at the boundary, done once, rather than sprinkled through the code that uses the value.

Business Rules Are Validation Errors

This is the convention that made the layering work, and it is the one I would carry to any project.

A service enforcing a rule throws the same error type a validator throws:

if (scheduledPickups.length >= DAILY_PICKUP_LIMIT) {
	throw new vineErrors.E_VALIDATION_ERROR([
		{
			field: 'pickupDate',
			message: 'Batas penjemputan per hari sudah penuh untuk tanggal ini.'
		}
	])
}

The payoff is that “the van is full that day” renders as an inline error under the date picker — exactly like “this is not a valid date” — instead of as a 500 page or a generic toast.

Why that matters beyond aesthetics: the rule can stay in the service and still produce a good UI. The alternative is duplicating capacity logic into the frontend so the form can show a nice message, and now the rule exists twice and one copy is wrong the moment the limit changes.

When the violation is not about a specific input, a pseudo-field carries it:

throw new vineErrors.E_VALIDATION_ERROR([
	{ field: 'status', message: 'Pesanan ini tidak sedang menunggu pembayaran.' }
])

status, id, radius, form — conventional names for “this failed, but not because of a field the user typed”. The page renders them at the top of the form rather than under an input.

Even the rate limiters use it:

export const loginLimiter = limiter.define('login', (ctx) => {
	return limiter
		.allowRequests(5)
		.every('1 minute')
		.blockFor('5 minute')
		.usingKey(`login:${ctx.request.ip()}:${ctx.request.input('phone')}`)
		.limitExceeded(() => {
			throw new errors.E_VALIDATION_ERROR([
				{ field: 'form', message: 'Terlalu banyak percobaan masuk. Silakan coba lagi nanti.' }
			])
		})
})

A rate-limited login shows a message on the login form. Not a 429 page, not a blank screen — the same place every other login problem appears.

Comments as Product Documentation

The validators in this codebase carry more prose than code in places, and it is the prose I would keep.

/**
 * Booking a pickup.
 *
 * The date has to be a future one. A collection booked for today cannot
 * actually happen: the van is already out on a route planned this morning,
 * and the customer would be left watching a stop that nobody is coming to.
 * Refusing it at the form is kinder than accepting it and disappointing them.
 */
export const orderValidator = vine.create({
	addressId: vine.number().positive(),
	pickupDate: vine.date().after('today')
})

.after('today') is three words and looks like an arbitrary strictness. The comment is the only place the operational reason exists — the van has already left. Without it, the obvious “improvement” is to allow same-day booking, and the bug that produces is a customer waiting at home for nobody.

Same with the walk-in form’s most confusing field:

/**
 * `totalItems` is the form's own field and stays exactly that: it is how many
 * item forms the page should draw, which the customer states before any of
 * them has been filled in. It is not `items.length` under another name —
 * that one is how many have been filled in so far, and the two differ for
 * the whole time the form is being completed.
 */

I flagged totalItems as dead weight when I wrote about the project — validated and never read by the service. That is still true of the service, and the comment is why it is still in the validator: it is a UI field that describes the form, not the order. Both facts are worth knowing, and only one of them is visible in the code.

And the ones that record a deliberate omission:

/**
 * The intake photo. A counter order skips inspection entirely, so without
 * one there is no record of what condition the shoes arrived in — which is
 * exactly the record a dispute turns on.
 */
photo: image(),
/**
 * What the customer handed over, so the system can work out the change
 * instead of somebody reaching for a calculator. Only cash has one.
 */
cashReceived: vine.number().positive().optional(),

A validator is where the contract of a form lives. Six months later the schema tells you what is allowed and the comments tell you why anybody decided that — and only one of those is reconstructible.

What I Would Do Differently

totalItems should be excluded explicitly. It is validated, documented, and ignored by the service. That is defensible, but a reader has to find the comment to know it is intentional. A naming convention for form-only fields — or simply not sending it — would say so without prose.

Some regexes could be named constants. /^08[1-9]\d{8,10}$/ appears once, which is fine. If a second place ever needs to check a phone format, the regex should move to a named export before it is copied.

Cross-field rules are still in services. “Cash received must be at least the total” lives in TransactionService, not in the walk-in validator, because the total is not in the payload. Correct, but it does mean payment validation is split across two layers and you have to know that.

None of those are the failure modes I set out to avoid. Rules do not drift, messages are written once, and no business rule is duplicated into the frontend to get a decent error message. That is what the layer was for.


Next: Part 3 — Lucid and PostgreSQL, where models are generated from migrations, partial indexes enforce rules the application cannot, and the numeric type quietly returns strings.

© 2026 r3p.dev. All rights reserved.