Osy#betaRun it on Osyrin: osyrin.com

Reference / Function

Functions (the unit of work)

A function is where your app's logic lives — a top-level unit of work, written like a C# method, that runs on the server. It is transactional by return: no Save(), no UnitOfWork.Commit(). It can call out to the world with no async and no Task, because the engine suspends and resumes it durably. And it contains no authorization code, because the entity's rules do that job — which is why a function is usually just the business problem, and nothing else.

stable7 examples compiled by CIfunctionlogicguide

Summary#

A function is the unit of work. It looks like a C# method, it lives at the top level of a file, and it runs on the server.

Four things are true of every one, and together they are the whole model:

  1. It is transactional by return. What it writes commits when it finishes. There is no Save() and no UnitOfWork.Commit().
  2. It has no colour. It can call out to the world — HTTP, a model, a file — with no async, no Task<T>, and no change to its signature or to anyone who calls it. (async and Task<T> are there, with C#'s meaning, for when you want two calls in flight at once — async / await and Task.)
  3. It contains no authorization code. It runs as the caller, and the entity's security { } rules decide what it is allowed to touch.
  4. A fault undoes it. If it throws, the rows it wrote are discarded — there is no half-done state to clean up.
entity Order {
  [Required, Unique, MaxLength(20)] string Code;
  decimal Total;
  invariant Total >= 0;

  security { allow create, read, update when IsAuthenticated || IsAnonymous; }    // the rules live HERE — never in the function
}

Order Place(string code, decimal total) {
  var order = new Order { Code = code, Total = total };
  return order;
}                       // ← committed here. No Save(), no auth check, no try/catch, no DTO.

Read what is absent: no repository, no transaction scope, no IsAuthorized call, no mapping to a response type. That absence is the point — the execution model explains why none of it exists.

Description#

It is a transaction, and the boundary is return#

Everything a function writes lands together, or not at all:

entity AuditLine {
  [Required, MaxLength(200)] string Message;
  security { allow create, read when IsAuthenticated || IsAnonymous; }
}

void PlaceAndLog(string code, decimal total) {
  var order = new Order { Code = code, Total = total };
  var line  = new AuditLine { Message = $"placed {code}" };
  // there is no state in which the order exists and the log line does not
}

You never write change tracking. The platform knows which rows you created and which fields you touched — no dirty flags, no diffing, no save pipeline. On the server you do not even choose when to persist; returning is the commit.

This is the one place the client differs, and it is worth knowing before it surprises you: in a UI action, the same new Order { … } is staged and shows on screen immediately, and it persists when a UnitOfWork.Commit() runs. Same statement, two moments — the asymmetry, and the reason for it, is in the execution model (§when data persists). Do not reach for UnitOfWork.Commit() in a server function.

Why does a call out need no async?#

This is the paragraph to understand. When a function reaches something that leaves the process — an HTTP call, a model completion, a file read — the engine suspends it, performs the effect, and resumes it at the next line, with every local still in place.

That suspension is durable. If the process is restarted, redeployed or killed while the call is in flight, the function still resumes where it left off. It is not a thread parked in memory; it is a continuation the platform persisted.

So you write this:

app Shop {
  model "model/**/*.osy";
  use Osysharp.Http;
}

string Fetch(string url) {
  var response = Http.Get(url);      // the function pauses here — you did not have to say so
  return response.IsSuccess ? response.Body : "";
}

No async. No Task<string>. Nothing about the signature says it might take a while, and no caller has to change when you add an outward call three layers down.

In C#, async is a colour: a method that awaits must be async, so its callers must await it, so they must be async too — it spreads until it reaches Main. The colour exists so a caller knows the callee might yield. Here that is the engine's business rather than the signature's: any function can suspend, so none has to advertise it. async is still there, with C#'s meaning, for the one thing it adds here: an un-awaited call to an async method runs BESIDE its caller, so two slow calls overlap (async / await and Task). There is nothing to spread, so there is nothing to mark. The full story, including the single place await does appear, is in async / await and Task — if you are coming from C#, it is the first habit to unlearn.

What crosses the wire, and what does not#

The engine hands off to the server when — and only when — a statement genuinely needs the server. It is worth knowing which those are, because the syntax hides them:

These reach the serverThese do not
a query or any read of stored data (Order.Single(…))pure computation — arithmetic, string work, comparisons
an effect — Http.*, File.*, Log.*, Memory.Searchcontrol flow — if, foreach, while, switch
a call to another functionnew T { … } and assignment — they accumulate in the unit of work
UnitOfWork.Commit() / cancel()reading fields of rows you already have
raising or starting a workflowbuilding and looping over a local List<T>

The right-hand column is the surprising one: creating a row and assigning to it do not force a round trip. They accumulate in the open unit of work and travel with it. So a loop that builds fifty rows is one unit of work, not fifty conversations.

The thing actually worth noticing is a loop that calls another function ten times — that is ten hand-offs. The syntax hides the round trip; the latency does not.

Security is ambient — a function has no auth code#

A function runs under the caller's security context, and the entity's security { } rules do the authorization. You do not check permissions in a function, and you should not try to.

This is not a convenience. A check you write is a check someone can forget to write; a rule on the entity is enforced for every path that touches it — this function, the next one, the UI, a workflow, an imported CSV — with no way to route around it.

Two consequences follow, and both catch people out.

user does not exist inside a function body. It is an ambient of the security rules, not of your code — writing user.Id in a function is an unknown-identifier error. There is deliberately no "current user" to read: authorization is a property of the data, expressed once on the entity, not a value you fetch and branch on.

The caller's identity still reaches the row anyway — through a declared stamp. A [CreatedBy] field is written from the security principal, which is the same thing user.Id resolves to inside a rule. That is what makes ownership work with one declared field and no assignment anywhere in your code ([CreatedAt] [CreatedBy] [ModifiedAt] [ModifiedBy]):

[Principal]
entity User {
  [Required, MaxLength(255)] string Email;
  security { allow read where Id == user.Id; }
}

entity Note {
  [Required, MaxLength(200)] string Title;
  [CreatedBy] Guid? CreatedBy;   // written by the platform, from the signed-in principal

  security {
    allow create when IsAuthenticated;
    allow read, update, delete where CreatedBy == user.Id;   // …so this is ownership, for free
  }
}

void Write(string title) {
  var note = new Note { Title = title };   // nobody sets an owner — the commit stamps CreatedBy.
}

Every reader of Note now sees only their own — including Note.Count(), which honestly means "how many notes are mine".

And the enforcement is real with nothing in the function at all. Write contains not one line of security code, so the only thing that can refuse it is the entity's rule — and it does:

[Test]
void The_function_has_no_auth_check_and_is_still_gated() {
  // Nobody is signed in. Note grants create only `when IsAuthenticated`, that rule is part of every
  // write, and so the create is refused — without `Write` knowing that security exists.
  Assert.Denied(() => Write("a note"));

  Assert.Empty(Note.ToList());
}

See the security guide for the whole model.

Errors#

A fault — one you throw, a broken invariant, a violated constraint — discards everything the function wrote and travels to the caller. There is no half-applied state, and nothing to unwind by hand.

void PlaceTwo(string first, string second) {
  var a = new Order { Code = first, Total = 10m };

  if (second == "") { throw new ValidationException("the second code is required"); }

  var b = new Order { Code = second, Total = 20m };
}

You can also handle one. Constraint and invariant violations arrive as an ordinary ValidationException, so a caller that wants to answer for a bad row rather than fail on it just catches it — and a try block that throws discards what it wrote, so the handler is never left holding a broken half-row:

  • throw — raising a fault, and the closed set of five types
  • try / catch / finally — catching one, when filters, finally, and the per-block rollback

What a signature may say#

Written like C#, with the deviations worth knowing:

decimal Quote(decimal amount, decimal rate = 0.25m, string? note = null) {
  return amount * (1m + rate);
}

decimal Two() {
  var a = Quote(100m);                      // rate defaults
  var b = Quote(100m, rate: 0.1m);          // named argument
  return a + b;
}
  • Return void, a scalar, an enum, an entity, a class, or a List<T> / T[]. Every path must return (function); a throw counts as a path.
  • Default parameter values and named arguments work as in C#. A nullable parameter (string? note) is optional even without a default — omit it and it binds null.
  • A default applies whoever calls. A REST endpoint, a tool call or osy run that leaves rate out gets 0.25m, exactly as Quote(100m) does. The published schema (the tool's input schema, the OpenAPI document) lists only the parameters with no default as required, and gives each constant default as default. A caller that leaves out a parameter with no default is refused, by its name, with the whole signature in the answer — the body never runs with a null it did not ask for. Sending null for a nullable parameter is null, not its default.
  • Overloads do not exist. Two functions may not share a name; give the second one a name that says what it does.
  • Recursion works, and is capped — a runaway function is stopped by the platform, and that stop cannot be caught (try / catch / finally).
  • Names are PascalCase; parameters are camelCase.

Who calls a function#

The same function, unchanged, is reachable from all of these — it does not know or care which one it is serving:

  • A UI action — Publish(draft); by name. The engine crosses the boundary (How an Osy# app works (the execution model)).
  • Another function — an ordinary call.
  • A workflow — as a state's work.
  • A test — [Test] calls it directly, with real data and real rules ([Test] / [TestFixture]).
  • The outside world — if you publish it. A function is exposed over REST or as a tool by declaring it in the app manifest; you never write an endpoint, and the function stays transport-agnostic. That surface is for other people's integrations, never for your own UI.

Where a function lives#

At the top level of a file, beside the data — not inside the entity. An entity body holds data and its rules; behaviour sits next to it. If you want behaviour attached to a type, with a receiver, that is a class method.

The pages#

Everything a function body may contain.

Declaring one

Control flow

Errors

Values and locals

The standard library, from a function body

See also#

Related

function

A function is a top-level unit of work, written like a C# method — a return type, a name, typed parameters, a body. It…

async / await and Task

C#'s async and await, with C#'s meaning. A call to a Task-returning method that you do not await where you make it…

try / catch / finally

Handles a fault. C#'s syntax, including typed catches, catch filters and finally. The rows written inside a try block…

throw

Raises a fault. It ends the function immediately, and the rows the function wrote are discarded rather than…

How an Osy# app works (the execution model)

Read this before you write anything. An Osy# app is ONE model — you do not build an API for it, and you do not write…

Querying data

How you read data in Osy#. You write C# LINQ; it becomes one SQL statement. The rules that follow from that are the…

The security model

How authorization works in Osy#, end to end. Everything is denied until you grant it; a grant is compiled into every…

[Test] / [TestFixture]

A test is an ordinary function marked [Test]. It runs against a throwaway clone of the app, so it may create rows and…

class methods

Behaviour attached to a class — a method with a receiver, called as value.Method(). Classes are in-memory values, so a…