A script include is a named piece of server-side JavaScript that other server scripts can call. It’s where logic goes once two places need it: the eligibility rule a business rule enforces and a catalog form previews, the routing logic both a flow and an inbound email action use. Writing one is easy. Writing one that can actually be reused, and tested, comes down to interface design, which is what interviewers probe when they ask “how would you structure this?”
What a script include is
Script includes live in System Definition > Script Includes and are loaded only when something calls them. That’s the main reason ServiceNow recommends them over global business rules for shared functions: a global business rule’s code is loaded everywhere, a script include only on demand.
There are three common shapes:
- A class, created with
Class.create()and aprototypeobject with aninitializemethod. Most script includes look like this, and the platform generates the skeleton for you. - An on-demand (classless) function, where the script include contains a single function with the same name as the record.
- A class extending another, with
Object.extendsObject(Parent, { ... }). Client-callable script includes extendAbstractAjaxProcessorthis way.
The record’s Name must match the class (and its type property) or the function name, or callers get an undefined reference.
Fields that decide who can call it
A few fields on the record matter as much as the code:
- Client callable (labelled Glide AJAX enabled in newer releases) makes the script include reachable from GlideAjax, list filters and reference qualifiers. It’s then protected by an ACL on the
executeoperation. Only tick it on classes written for browser callers. - Accessible from chooses between This application scope only and All application scopes. Internal helpers should stay private to their app.
- API name is the namespaced name other scopes use, such as
x_acme_it.LaptopRefreshEligibility. - Protection policy (None, Read-only, Protected) matters when you distribute an app and don’t want customers editing or reading the code.
Designing the interface
Reusable means “callable from places the author didn’t have in mind”. Three rules make that true.
Pass facts in, return a plain result. A method that takes the values it needs as arguments and returns an object like { eligible, reason } can be called by a business rule, a flow action, a Scripted REST resource or a test. A method that reads current, g_form or gs.getUserID() internally only works in the context it was written for.
Separate loading from deciding. Querying tables is one job; applying the policy is another. Keeping them in separate methods means the decision can be exercised with hand-made inputs, and the query can be optimised without touching the rules.
Don’t surprise the caller. Return a value instead of writing to the caller’s record, and don’t swallow errors silently. If the method can’t decide, say so in the result.
Running the pattern in Node
Script include code is plain JavaScript once the platform helpers are provided. This file adds a one-line Class.create shim, defines a laptop refresh eligibility class written the way you’d write it on the platform, calls it the way three different callers would, and then shows the same logic written against current being called from a scheduled job:
// Minimal shims for the platform's Prototype-style helpers, so script include code runs in Node.
var Class = { create: function () { return function () { this.initialize.apply(this, arguments); }; } };
// ---- The script include: explicit inputs in, a plain result out. ----
var LaptopRefreshEligibility = Class.create();
LaptopRefreshEligibility.prototype = {
initialize: function (policy) {
this.policy = policy || { standardMonths: 36, engineeringMonths: 24 };
},
// facts: { department, purchased (YYYY-MM-DD), openRefreshRequests, today (YYYY-MM-DD) }
check: function (facts) {
if (facts.openRefreshRequests > 0) return { eligible: false, reason: 'A refresh request is already open' };
var limit = facts.department === 'Engineering' ? this.policy.engineeringMonths : this.policy.standardMonths;
var age = this._monthsBetween(facts.purchased, facts.today);
return age >= limit
? { eligible: true, reason: 'Device is ' + age + ' months old (limit ' + limit + ')' }
: { eligible: false, reason: 'Device is ' + age + ' months old; eligible at ' + limit };
},
_monthsBetween: function (from, to) {
var a = from.split('-').map(Number), b = to.split('-').map(Number);
return (b[0] - a[0]) * 12 + (b[1] - a[1]) - (b[2] < a[2] ? 1 : 0);
},
type: 'LaptopRefreshEligibility'
};
// ---- Callers: a business rule, a GlideAjax method and a scheduled job all reuse it. ----
var rules = new LaptopRefreshEligibility();
var today = '2026-10-12';
[
{ who: 'Priya (Finance)', department: 'Finance', purchased: '2023-09-30', openRefreshRequests: 0 },
{ who: 'Marco (Engineering)', department: 'Engineering', purchased: '2024-10-13', openRefreshRequests: 0 },
{ who: 'Lena (Engineering)', department: 'Engineering', purchased: '2022-01-15', openRefreshRequests: 1 },
].forEach(function (f) {
var r = rules.check(Object.assign({ today: today }, f));
console.log(f.who.padEnd(20) + (r.eligible ? 'eligible ' : 'not yet ') + r.reason);
});
// ---- The same logic written against `current`, then called from a scheduled job. ----
var CurrentBoundEligibility = Class.create();
CurrentBoundEligibility.prototype = {
initialize: function () {},
check: function () { return current.u_department == 'Engineering'; }, // ambient state
type: 'CurrentBoundEligibility'
};
try { new CurrentBoundEligibility().check(); }
catch (e) { console.log('scheduled job: ' + e.name + ': ' + e.message); }Priya (Finance) eligible Device is 36 months old (limit 36)
Marco (Engineering) not yet Device is 23 months old; eligible at 24
Lena (Engineering) not yet A refresh request is already open
scheduled job: ReferenceError: current is not definedThe class makes one decision from four facts, so its edge cases are easy to probe. Marco’s device is one day short of 24 months, so he’s not yet eligible; Priya is exactly at the limit. Those boundaries are where policies get disputed, and here they can be tested in seconds without creating hardware records.
The last line is the failure the design avoids. The current-bound version works in a business rule, where the platform provides current, and throws as soon as a scheduled job, a flow script or a REST resource calls it. On the instance the message text differs slightly (it’s a Rhino error rather than a Node one), but the cause is the same: shared code depended on ambient state.
On the platform: one rule, several callers
On the instance, the class gains a method that loads the facts, and each caller is a thin adapter. An illustrative version (not run on an instance; the table and field names follow the base asset tables but should be checked against yours):
// Added to LaptopRefreshEligibility.prototype
factsFor: function (userId, today) {
var user = new GlideRecord('sys_user');
if (!user.get(userId)) return null;
var device = new GlideRecord('alm_hardware');
device.addQuery('assigned_to', userId);
device.addQuery('model_category.name', 'Computer');
device.orderByDesc('purchase_date');
device.setLimit(1);
device.query();
if (!device.next()) return null;
var open = new GlideAggregate('sc_req_item');
open.addQuery('requested_for', userId);
open.addQuery('cat_item.name', 'Laptop refresh');
open.addQuery('active', true);
open.addAggregate('COUNT');
open.query();
return {
department: user.department.getDisplayValue(),
purchased: device.getValue('purchase_date'),
openRefreshRequests: open.next() ? parseInt(open.getAggregate('COUNT'), 10) : 0,
today: today
};
},A before-insert business rule enforces the policy when a refresh is requested:
// Business rule: before insert on sc_req_item, Condition: current.cat_item.name == 'Laptop refresh'
(function executeRule(current, previous) {
var rules = new LaptopRefreshEligibility();
var facts = rules.factsFor(current.getValue('requested_for'), new GlideDate().getValue());
var verdict = facts ? rules.check(facts) : { eligible: false, reason: 'No assigned laptop found' };
if (!verdict.eligible) {
gs.addErrorMessage(verdict.reason);
current.setAbortAction(true);
}
})(current, previous);And a separate client-callable class lets the catalog form preview the same answer for the logged-in user:
var LaptopRefreshAjax = Class.create();
LaptopRefreshAjax.prototype = Object.extendsObject(global.AbstractAjaxProcessor, {
preview: function () {
var rules = new LaptopRefreshEligibility();
var facts = rules.factsFor(gs.getUserID(), new GlideDate().getValue());
return JSON.stringify(facts ? rules.check(facts) : { eligible: false, reason: 'No assigned laptop found' });
},
type: 'LaptopRefreshAjax'
});Notice what isn’t shared. The Ajax class decides whose eligibility to check (always the session user, never a parameter), and the business rule decides what to do with a “no” (abort the insert). The policy itself lives in exactly one place, so changing 36 months to 48 is a one-line change with one set of tests.
Smells to listen for
- The 3,000-line
Utilsclass. Unrelated functions in one script include load together, change together and get edited by everyone. Split by domain. - Queries in
initialize. Everynewpays for them, even when the caller only needs one cheap method. - Returning GlideRecord objects to callers in other scopes or to Ajax wrappers. Return plain data, which can be serialised, compared and tested.
- Client callable on everything. Every client-callable class is an endpoint reachable from the browser; keep that surface small and deliberate.
- Catch-and-log-and-continue. A
try/catchthat logs and returnsundefinedturns a clear failure into a mysterious one three calls later.
Interview practice
A colleague wrote a script include method isEligible() that reads current.requested_for and gs.getUser().getDepartmentID(), and it’s called from a business rule on sc_req_item. Now a flow and a nightly scheduled job need the same check for other users. What’s wrong, and how would you refactor it?
Answer
The method depends on two kinds of ambient state: current, which only exists in contexts like business rules, and the session user, which in a scheduled job is the system account and in a flow depends on how the flow runs. Called from the job, it either throws because current isn’t defined or quietly checks the wrong person.
I’d change the signature so the caller supplies who and what: check(facts) taking the department, device purchase date, open request count and date, plus a factsFor(userId, today) method that loads those facts with queries. The business rule passes current.getValue('requested_for'), the flow passes the user from its data pill, and the job loops over users and passes each sys_id. The decision method then has no platform dependencies at all, so its boundaries (exactly at the limit, one day short, an open request already present) can be covered by an ATF Run Server Side Script step or plain unit tests. Finally I’d keep the script include server-only and wrap it in a separate client-callable class only if a form needs a preview.
Related reading
- ServiceNow interview questions
- ServiceNow multiple-choice questions
- ServiceNow GlideAjax and Asynchronous Client Calls, for the client-callable wrapper in detail
- ServiceNow Automated Test Framework and Release Evidence, on testing script includes on the instance
- Script includes (ServiceNow documentation)