Ch. 17 · ServiceNow

ServiceNow Script Includes and Reusable Server Logic

Design ServiceNow script includes with explicit inputs and plain results, so business rules, flows and GlideAjax can share one implementation.

~7 min readintermediateupdated Oct 12, 2026

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 a prototype object with an initialize method. 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 extend AbstractAjaxProcessor this 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 execute operation. 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); }
JavaScript
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 defined
Text

The 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
    };
},
JavaScript

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);
JavaScript

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'
});
JavaScript

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 Utils class. Unrelated functions in one script include load together, change together and get edited by everyone. Split by domain.
  • Queries in initialize. Every new pays 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/catch that logs and returns undefined turns 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.

More in ServiceNow

esc