Application structure

Example application architecture

Canton applications have three layers, User Interface, Local Business Logic and State, and Consensus Business Logic and State. These may look like the traditional 3-tier architecture of User Interface, Business Logic, and Database, but treating them this way results in underperforming applications that generate unnecessary traffic on the Global Synchronizer.

Don’t treat the blockchain as a database. While they have similar features, standard database design techniques are not optimized for blockchains. Instead, think about whether operations need consensus from multiple parties or can be handled locally by a single participant, in other words, think in terms of consensus vs. local state.

  • Local Business Logic and State: Actions and data that a single participant node can handle independently without consensus from others.

  • Consensus Business Logic and State: Actions and data that require agreement from multiple parties and must be handled using Daml smart contracts.

Frontend

User Interface

HTTP/JSON

Backend Services

Local Business Logic and State

GRPC/Protobuf

HTTP/JSON

Daml Models

Consensus Business Logic and State

If your backend is full of CRUD operations, you’re treating the blockchain like a database. Canton synchronizes data between organizations with limited trust, so operations should represent business processes, not simple data updates.

Canton’s privacy guarantees require that business logic needing authorization or verification by multiple parties be implemented in Daml smart contracts. The Global Synchronizer coordinates consensus on authorization, verification, and visibility.

See the Daml Philosophy Course 2 “Daml Workflows” for details regarding the distinction between local vs. consensus logic and state.

Alternative application architecture

The Quickstart application uses backend services as a middle layer between the frontend and Daml models. This is known as a fully mediated architecture.

  • Frontend → Backend Services → Daml Models (for writes)

  • Frontend → Backend Services → Daml Models (for reads)

The backend “mediates” (acts as a middle layer for) every interaction between the frontend and the consensus layer.

Alternatively, the Quickstart application could have used a CQRS-style (Command Query Responsibility Segregation) architecture, where frontend actions bypass the backend and go directly (unmediated) to the consensus business operations.

  • Frontend → Daml Models directly (for writes)

  • Frontend → Backend Services → Daml Models (for reads)

In this example of a CQRS model, the user interface updates (writes) go directly to Daml models rather than being mediated through backend services. User interface queries (reads) still use backend services, which also handle external integrations and automation.

Frontend

User Interface

HTTP/JSON

Ledger Update Operations

Backend Services

Queries, Automation and Integration

GRPC/Protobuf

HTTP /JSON

Daml Models

Consensus Business Logic and State

For a detailed discussion on options for application architectures see the free courses in the Technical Solution Architect Certification.

Daml model structure

Let’s look at how the consensus business logic layer works in practice. The Quickstart uses a licensing application with two Daml modules, AppInstall and License. Util is infrastructure that contains helper functions that fall outside our area of interest. Each module handles different aspects of user onboarding and license management.

% tree licensing
licensing
├── daml
│  └── Licensing
│     ├── AppInstall.daml   # User onboarding workflow
│     ├── License.daml      # License creation and renewal
│     └── Util.daml         # Shared utilities
└── daml.yaml

3 directories, 4 files

The core business flow requires consensus between multiple parties (the app-provider, app-user, and DSO), so it lives entirely in the consensus layer through these Daml smart contracts:

AppInstall.daml handles user onboarding through two templates: - AppInstallRequest - A user requests to install the app - AppInstall - The provider accepts, creating an active installation that can generate licenses

License.daml handles time-based access control with two templates: - License - Grants access for a specific time period - LicenseRenewalRequest - Handles license extensions through payments

These operations require consensus because they involve agreements between multiple parties.

For example, license renewal requires the user to pay, the provider to validate the payment, and the DSO to process the payment through its system. This multi-party coordination is why these operations belong in the consensus business logic layer, rather than the local backend services.

In Quickstart, the workflow follows this sequence: 1. User creates an AppInstallRequest (consensus - provider must see and respond to the request) 2. Provider exercises AppInstallRequest_Accept to create an AppInstall (consensus - both parties must agree) 3. Provider creates License contracts for specific features or time periods (consensus - user must accept terms) 4. License renewal involves payment validation across all three parties (consensus - user and provider require DSO’s payment system)

Each step requires multi-party agreement, which is why it belongs in the consensus business logic layer rather than being handled by local backend services.

Key Daml templates

AppInstallRequest contract

The AppInstallRequest contract initiates the app user onboarding process by capturing a user’s request to install the application. The contract gives the application provider control over application access to accept or reject installation requests. This contract offers three choices that extend the Propose/Accept pattern to allow the user to cancel the request.

The AppInstallRequest_Accept choice allows the provider to accept the request. When the choice is executed, it creates a new AppInstall contract and makes the provider and user signatories.

The AppInstallRequest_Reject choice allows the provider to decline the request. It archives the request contract and also records metadata about why the request was rejected in the ledger exercise event.

The AppInstallRequest_Cancel choice allows the user to withdraw their request any time before the provider accepts the contract.

AppInstall contract

The AppInstall contract maintains the formal relationship between the provider and user. It tracks installation status and manages license creation. The contract has two choices, AppInstall_CreateLicense and AppInstall_Cancel.

AppInstall_CreateLicense allows the provider to create a new license for the user. When the CreateLicense choice is exercised it creates a new License contract. It also increments numLicensesCreated to track how many licenses exist which is used to assign each license a license number. Note: Daml smart contracts are immutable, so “incrementing” the counter results in archiving the current AppInstall contract and creating a new one with the updated counter, within the same atomic transaction.

AppInstall_Cancel lets the provider or user cancel the installation.

License Contract

The License contract is the on-ledger record supporting the core business case for the application. One critical field is the expiresAt field, which both determines the duration of the license’s validity, and is used to ensure that neither actor can revoke (archive) the license contract before expiry. The contract also has two choices:

License_Renew can be exercised by the license provider. It creates a Splice AppPaymentRequest and a LicenseRenewalRequest contract. The former is a part of the Splice Wallet Application, and is used to request an amulet transfer. The choice of amulet is made via the DSO party used in the AppInstall contract. The current deployment configuration results in this being Canton Coin; however, there is nothing in the Daml model, or the backend code, that prevents a different amulet from being used.

The License_Expire choice allows either party to archive an expired License contract.

Common OpenAPI definition

Daml models define the consensus between the App Provider, App User, and the DSO (amulet issuer). Once the models are in use, the frontend user interface needs to be able to query and interact with the resulting ledger. The usual pattern is to store and index the relevant slice of the ledger in the Participant Query Store and provide a set of query web services that provide business-oriented queries resolved against the PQS postgres database.

The architecture used by the example application also exposes a variety of HTTP endpoints that allow the frontend to exercise choices, providing a bridge between the frontend and the GRPC Ledger API. This allows the backend to centralise authentication and access control code.

This does necessitate defining an API between the back and front ends. For this example application, we have chosen to use OpenAPI. The API definition is in common/openapi.yaml. It uses GET to access the query services in the backend, and POST to execute choices on contracts identified by contract-id in the URL.

Note: The HTTP method semantics align appropriately with the requirements of the Daml operations and we call this a “JSON API”. However, it is not a pure ReST API and does use HATEOAS. As mentioned above, the blockchain should not be viewed as a database since the underlying state is not rows in a database, or objects in a datastore, either of which would be compatible with the CRUD-style semantics that emerge with most modern ReST tooling. Instead the architecture style used here is more akin to a sophisticated RPC mechanism, where Contract-ids and their underlying contract are nouns and can be represented as ReST resources. However, not only does this fail to capture the ongoing business entity that often outlives any single contract, it misses the fact that at the core of Daml are the authorized choices which are verbs and therefore do not play nicely with ReST assumptions.

Backend services structure

The example backend is a SpringBoot application, at the core of which are the API implementation classes in com.digitalasset.quickstart.service.

Most of this code is standard Java SQL-backed JSON-encoded HTTP web services. The code is divided into several modules under com.digitalasset.quickstart.*:

config: Standard SpringBoot @ConfigurationProperties based components.

security: Handles security related-demands including OAuth2, shared-secret auth support, roles, and access control.

service: Implements the openAPI endpoints, including read-only calls to PQS via the DamlRepository spring component and GRPC calls to the relevant validator via the LedgerApi spring component.

ledger: The main class here is LedgerApi which handles the details of calling the relevant GRPC endpoints required to submit Daml commands and other requests to the Canton Validator.

repository: Includes `DamlRepository`. A @Repository component providing business-logic level query and retrieval facilities against the ledger via PQS (the Participant Query Store).

pqs: The main class is Pqs, which provides data-model level query and retrieval. This encapsulates the necessary SQL generation and the JDBC queries against the PQS Postgres database.

utility: Miscellaneous utility code including JsonUtil for JSON encoding and decoding, and DamlCodeGen which provides access to the Daml model-generated Java classes.

Ultimately, the main recommendation embedded in this code is to orient the web-service API around a combination of queries and choice invocations. This is hopefully adequately demonstrated in the open API definition. Other than that, the usual web service engineering considerations apply: separation of concerns, DRY, and the importance of centralizing SQL generation and authentication mechanisms to ensure we address these security sensitive components only once.

Frontend interface structure

One property of the fully mediated architecture used in the example application is that by delegating all operations to the backend, the open API schemas act as DTO (Data Transfer Object) definitions for the front and back ends. In simple cases, such as the example application, these can double as frontend models when using React, MVVM, FRP, or a similar frontend architecture style.

The CQRS alternative architecture does not use DTOs. Instead the backend services return Daml contracts directly. These are then generally deserialised directly into Javascript or Typescript objects, generated directly from the DAR files; and, used to populate the underlying frontend model. This direct coupling from Daml to Frontend can significantly simplify the code required for applications with requirements defined in terms of a Daml model. The mediated architecture is more suitable where the Frontend needs to incorporate sources of data additional to the Canton Ledger.

The example application is a naive React web frontend written in Typescript. It accesses the backend web services using the generator-less Axios client to handle the lowest-level transport, configured in src/api.ts:

import OpenAPIClientAxios from 'openapi-client-axios';
import openApi from '../../common/openapi.yaml';

const api = new OpenAPIClientAxios({
     definition: openApi as any,
     withServer: { url: '/api' },
});

api.init();

export default api;

Authentication is handled using OAuth2 against a mock OAuth server to perform the login; and, bearer tokens to identify the frontend to the backend. The frontend does not have any knowledge of Canton or Daml users or parties, this is delegated entirely to the backend.

The records defined by the OpenAPI definition are used directly as the models maintained within the React stores, and from there to the views via the usual React handlers.

Tooling in the Quickstart

For testing and experimentation there is a make target to create the AppInstallRequest on behalf of the app user party.

.PHONY: create-app-install-request
create-app-install-request: ## Submit an App Install Request from the App User participant node
docker compose -f docker/app-user-shell/compose.yaml \
$(DOCKER_COMPOSE_ENVFILE) run --rm create-app-install-request || true

This uses curl via a utility function curl_check to submit a Daml Create command to Org1’s participant node via its HTTP Ledger JSON API (v2/commands/submit-and-wait).

% cat docker/app-user-shell/scripts/create-app-install-request.sh
 #!/bin/bash
 ...
 source /app/utils.sh

 create_app_install_request() {
     curl_check "http://$participant:7575/v2/commands/submit-and-wait" \
     "$token" "application/json" \
     --data-raw '{
         "commands" : [
             { "CreateCommand" : {
                 "template_id": "#quickstart-licensing:Licensing.App Install:AppInstallRequest",
                 "create_arguments": {
                     "dso": "'$dsoParty'",
                     "provider": "'$appProviderParty'",
                     "user": "'$appUserParty'",
                     "meta": {"values": []}
                 }
             } }
         ]
     }'
 }

create_app_install_request "$LEDGER_API_ADMIN_USER_TOKEN_APP_USER" \
$DSO_PARTY $APP_USER_PARTY $APP_PROVIDER_PARTY participant-app-user

Running this and then using Daml Shell (make shell provides a useful shortcut) to inspect the result on the ledger.

% make shell
 docker compose -f docker/daml-shell/compose.yaml --env-file .env run \
 --rm daml-shell || true
 Connecting to
 jdbc:postgresql://postgres-splice-app-provider:5432/scribe...
 Connected to
 jdbc:postgresql://postgres-splice-app-provider:5432/scribe
 postgres-splice-app-provider:5432/scribe> active
 ┌─────────────────────────────────────────────────────────────┬──────────┬───────┐
 │ Identifier                                                  │ Type     │ Count │
 ╞═════════════════════════════════════════════════════════════╪══════════╪═══════╡
 │ quickstart-licensing:Licensing.AppInstall:AppInstallRequest │ Template │   1   │
 ├─────────────────────────────────────────────────────────────┼──────────┼───────┤
 │ splice-amulet:Splice.Amulet:ValidatorRight                  │ Template │   1   │
 ├─────────────────────────────────────────────────────────────┼──────────┼───────┤
 │ splice-wallet:Splice.Wallet.Install:WalletAppInstall        │ Template │   1   │
 └─────────────────────────────────────────────────────────────┴──────────┴───────┘
 postgres-splice-app-provider:5432/scribe 3f → 42> active
 quickstart-licensing:Licensing.AppInstall:AppInstallRequest
 ┌─────────┬──────────┬───────────┬───────────────────────────────────────────────┐
 │ Created │ Contract │ Contract  │ Payload                                       │
 │ at      │ ID       │ Key       │                                               │
 ╞═════════╪══════════╪═══════════╪═══════════════════════════════════════════════╡
 │ 42      │ 0058df2  │           │ dso: DSO: :1220c93d1...                       │
 │         │ 3a5aaa4  │           │ meta:                                         │
 │         │ c2a53a...│           │   values:                                     │
 │         │          │           │ user: Org1: :12203a9a7...                     |
 │         │          │           │ provider: AppProvider: :122030b08cfebb8c8...  │
 └─────────┴──────────┴───────────┴───────────────────────────────────────────────┘
 postgres-splice-app-provider:5432/scribe 3f → 42> contract
 0058df23a5aaa4c2a53aab496d12fb9e8ee74fb91614e5f7d50670598e4760eb23ca101220cc241620b310c93af45b2cd7cea7518e18e26f73f227813fec2bf4ea0bd69b940120cc241620b310c93af45b2cd7cea7518e18e26f73f227813fec2bf4ea0bd69b94
 ╓───────────────────────╥─────────────────────────────────────────────────────────────╖
 │ Identifier            ║ quickstart-licensing:Licensing.AppInstall:AppInstallRequest ║
 ╟───────────────────────╫─────────────────────────────────────────────────────────────╢
 │ Type                  ║ Template                                                    ║
 ╟───────────────────────╫─────────────────────────────────────────────────────────────╢
 │ Created at            ║ 42 (not yet active)                                         ║
 ╟───────────────────────╫─────────────────────────────────────────────────────────────╢
 │ Archived at           ║ <active>                                                    ║
 ╟───────────────────────╫─────────────────────────────────────────────────────────────╢
 │ Contract ID           ║ 0058df23a5aaa4c2a53a...                                     ║
 ╟───────────────────────╫─────────────────────────────────────────────────────────────╢
 │ Event ID              ║ #12201612fb8a071e27ec...:0                                  ║
 ╟───────────────────────╫─────────────────────────────────────────────────────────────╢
 │ Contract Key          ║ <not set>                                                   ║
 ╟───────────────────────╫─────────────────────────────────────────────────────────────╢
 | Payload               ║ dso: DSO: :1220c93d13220b07f0e9a0a0f7a2381191d3bf3d21...    │
 |                       ║ meta:                                                       │
 |                       ║   values:                                                   │
 |                       ║ user: Org1: :12203a9a79d8f72b8cce37813713af7a51296def8...   │
 |                       ║ provider: AppProvider: :122030b08cfebb8c87c16793cba3783...  │
 ╚═══════════════════════╩═════════════════════════════════════════════════════════════╝
 postgres-splice-app-provider:5432/scribe 3f → 42>

Exercising the AppInstallRequest_Accept choice completes the onboarding. The frontend UI provides a way to do this.