Skip to content
Private beta

ORM and GraphQL server for Java and PostgreSQL

Simplify working with relational data and creating network services. Loppi autogenerates code so you can have a GraphQL schema and database that you can use with type-safety.

Example architecture

Client browser · service HTTP YOUR JAVA APPLICATION HTTP server embedded Loppi GraphQL parses request ORM builds SQL SQL PostgreSQL your database

ORM

Your data layer, in plain Java

public record ProductDTO(long productId, String description, BigDecimal price){}
// Fetches a number of products, ordered by createdDate, filtered by description.
// These are autogenerated: databaseService, ProductScout, ProductResult and productMeta
public ImmutableList<ProductDTO> fetchProducts(int limit)
        throws PostgresExecutionException {
    ScoutQueryBuilder<ProductDTO, ProductResult> queryBuilder =
        databaseService.createScoutQueryBuilder(limit, ProductScout.class,
                productScout -> new ProductDTO(
                        // Include these three columns in the query.
                        productScout.scoutProductId(),
                        productScout.scoutDescription(),
                        productScout.scoutCurrentUnitPrice()
                )
        );
    queryBuilder.tableQueryBuilder()
            .orderBy(productMeta.createdAt, OrderByDirection.asc)
            .where().like(productMeta.description, "wall clock");
    return databaseService.executeScoutQuery(queryBuilder);
}
public record PurchaseOrderDTO(
        String name, String address, String phoneNumber, OffsetDateTime createdAt,
        List<OrderLineDTO> orderLines){
    public record OrderLineDTO(long orderLineId, long productId, int quantity){}
}
// Builds the query for purchase orders and their order lines. The mapping
// function decides which columns are fetched and adds a lateral join for the
// order lines. The MUTATION tab reuses this same method.
public ScoutQueryBuilder<PurchaseOrderDTO, PurchaseOrderResult> createFetchOrdersQuery(
        int ordersLimit, int orderLinesLimit) {
    ScoutQueryBuilder<PurchaseOrderDTO, PurchaseOrderResult> scoutQueryBuilder =
        databaseService.createScoutQueryBuilder(ordersLimit, PurchaseOrderScout.class,
        purchaseOrderScout -> {
            // Include lateral join for order lines, the number of order lines per
            // order is limited, and order lines themselves are ordered by orderLineId
            List<OrderLineScout> orderLineScouts = purchaseOrderScout.scoutOrderLines(
                    orderLinesLimit, queryBuilder -> queryBuilder
                            .orderBy(orderLineMeta.orderLineId, OrderByDirection.asc)
            );
            List<PurchaseOrderDTO.OrderLineDTO> orderLines = new ArrayList<>();
            for (OrderLineScout orderLineScout : orderLineScouts) {
                orderLines.add(new PurchaseOrderDTO.OrderLineDTO(
                        // Include the scalar columns from the order line table.
                        orderLineScout.scoutOrderLineId(),
                        orderLineScout.scoutProductId(),
                        orderLineScout.scoutQuantity())
                );
            }
            return new PurchaseOrderDTO(
                    // Include the scalar columns from the purchase order table.
                    purchaseOrderScout.scoutName(),
                    purchaseOrderScout.scoutAddress(),
                    purchaseOrderScout.scoutPhoneNumber(),
                    purchaseOrderScout.scoutCreatedAt(),
                    orderLines);
        });
    scoutQueryBuilder.tableQueryBuilder().orderBy(
            purchaseOrderMeta.createdAt, OrderByDirection.asc);
    return scoutQueryBuilder;
}
// Fetches purchase orders and their order lines, ordered by createdAt.
public ImmutableList<PurchaseOrderDTO> fetchOrders(int ordersLimit, int orderLinesLimit)
        throws PostgresExecutionException {
    return databaseService.executeScoutQuery(
            createFetchOrdersQuery(ordersLimit, orderLinesLimit));
}

Choose your columns where you map them

A query is a normal Java method. As you map a row into your own record, the fields you read are the columns and joins Loppi actually fetches. The query and the result are written in one place, so they stay in sync.

One place to change

Add a field to your record and read its column on the same line. Remove the field and the column drops out of the SQL. There is no separate select list to keep updated.

Joins where you use them

When a record holds a nested one, Loppi loads it with a lateral join in the same query. Children come back with their parents, so you don't run into the N+1 problem.

Plain records, fully loaded

You get back the immutable record you defined. No proxies, no lazy loading, and nothing that throws a LazyInitializationException once the method returns.

public record ProductIdAndQuantity(long productId, int quantity){}
// Places a purchase order and corresponding order lines in one transaction.
public ImmutableList<PurchaseOrderDTO> placeOrder(
        String name, String address, String phoneNumber,
        List<ProductIdAndQuantity> orderLineInfo)
        throws PostgresExecutionException {
    List<RetailDemoRowToMutate> transaction = new ArrayList<>();
    PurchaseOrderToInsert purchaseOrderToInsert =
            new PurchaseOrderToInsert(name, address, phoneNumber);
    transaction.add(purchaseOrderToInsert);
    for (ProductIdAndQuantity productIdAndQuantity : orderLineInfo) {
        transaction.add(
                new OrderLineToInsert(purchaseOrderToInsert,
                        productIdAndQuantity.productId(),
                        productIdAndQuantity.quantity())
        );
    }
    // Reuse createFetchOrdersQuery() from the QUERY 2 tab to return the placed
    // order with its order lines, in the same transaction as the insert.
    MutationScoutQueryExecutionResult<PurchaseOrderDTO> result =
            databaseService.executeMutationScoutQuery(
                    transaction, createFetchOrdersQuery(1, 50));
    return result.getScoutQueryExecutionResult();
}

Save a whole object graph in one transaction

Put together a graph of the insert and update objects Loppi generates, like an order with its order lines, and pass it to Loppi. It inserts and updates every row in the right tables, in the right order, in a single transaction.

Insert and update a graph

Parent and children go in together. Loppi works out the foreign keys and the order of the writes, so you are not copying generated ids from one insert into the next.

All or nothing

The whole graph commits, or none of it does. You won't be left with a half-written order to clean up.

No session to manage

You fill in the generated insert objects and hand them over. There is no entity state to manage, nothing to attach or detach, and no flush to time.

GraphQL server

The same data, as a GraphQL API

// There is a table in the database called "product". This block of code will
// configure a GraphQL query field. ProductMeta is autogenerated.
GraphQLTablePluralQueryConfiguration<ProductResult> productQueryConfiguration =
        new GraphQLTablePluralQueryConfiguration<ProductResult>()
        .setIncludeField(true)
        .setPluralFieldDescription("Returns the available products.")
        // The fields that a customer will be allowed to order by in the queries.
        .setIncludeOrderBy(Set.of(productMeta.stockCode,
                productMeta.productId,
                productMeta.currentUnitPrice,
                productMeta.createdAt))
        // The fields that a customer will be allowed to filter by in the queries.
        .setIncludeWhere(Set.of(productMeta.productId,
                productMeta.currentUnitPrice,
                productMeta.description,
                productMeta.stockCode,
                productMeta.createdAt))
        .setIncludePagination(true);
GraphQLTableQueriesConfiguration<ProductResult> queriesConfiguration =
        new GraphQLTableQueriesConfiguration<>(
        // The fields that a customer with be allowed to include in the queries.
        Set.of(productMeta.productId,
                productMeta.description,
                productMeta.stockCode,
                productMeta.currentUnitPrice,
                productMeta.createdAt))
        .setIncludePluralTableQuery(productQueryConfiguration);
return new GraphQLTableConfiguration<>(productMeta.getEntityMetadata())
        .setIncludeQueries(queriesConfiguration);
#  GraphQL SDL of the schema generated
"Returns the available products."
products(
  "Limit the number of results."
  limit: Int!,
  "Order by argument for the result list. Cannot be null."
  orderBy: [ProductOrderBy!],
  """This adds certain where-criteria. When next page is selected it works
  such that only elements after lastSeenId are returned. When previous page
  is selected it only returns elements before lastSeenId. When using
  pagination, you must also specify orderBy with exactly one or two entries.
  The last entry in the orderBy must be the productId field."""
  pagination: ProductPagination,
  """Boolean expression for determining which results should be included.
  Cannot be null."""
  where: ProductCriteria
): [ProductResult!]!

type ProductResult {
  createdAt: OffsetDateTime!
  currentUnitPrice: BigDecimal!
  description: String!
  productId: Int!
  stockCode: String!
}

Programmatically declare your GraphQL schema

Declare what tables, columns and relations you want to expose, how they are allowed to be ordered and filtered, and how they can be mutated. Fields that do not map directly to database tables can be declared as custom fields.

Only expose what is needed

Adding fields for querying your database is easy, and so is adding custom fields for everything else.

Add common query options with a single line

Relationships to join, where or orderBy expression, all with type-safety.

Everything you need in one endpoint

Maintain and secure a handful of fields instead of hundreds of endpoints.

# Fetches the 5 latest purchase orders for the
# current user, and the first 8 orderlines for
# each purchase order.

query myOrders {
    purchaseOrders(limit:5, orderBy: {createdAt:desc}){
        purchaseOrderId
        createdAt
        orderStatus

        # This fetches orderlines for the purchase
        # order, but does not cause multiple fetches,
        # round-trips or the N+1 problem.
        orderLines(limit: 8 orderBy: { orderLineId: desc})
        {
            unitPrice
            quantity
            product {
                productId
                description
            }
        }
    }
}
{
  "data": {
    "purchaseOrders": [
      {
        "purchaseOrderId": 581796,
        "createdAt": "2023-08-21T10:38:19.952764+00:00",
        "orderStatus": "NEW",
        "orderLines": [{
          "unitPrice": "0.12", "quantity": 1,
          "product": { "productId": 134, "description": "DISCO BALL CHRISTMAS DECORATION" }
        }]
      },
      {
        "purchaseOrderId": 581795,
        "createdAt": "2023-08-21T10:37:22.637079+00:00",
        "orderStatus": "NEW",
        "orderLines": [{
          "unitPrice": "0.21", "quantity": 1,
          "product": { "productId": 28, "description": "ANIMAL STICKERS" }
        }]
      }
    ]
  }
}

Fetch the data you need in one request

With one schema there are many ways to query it. Fetch multiple root fields, their relations and their relations again. No need for several http requests to get the data for one page.

No overfetching

Don't call a large endpoint and discard most of the data, instead fetch exactly what you need and no more.

No N+1 queries

One root field, many root fields, relations, it doesn't matter; it's all fetched in one go.

One round-trip one transaction

From Loppi to the database, the query or mutation is executed in one round-trip and one transaction.

# Example of the mutations part of an e-commerce GraphQL schema
"Root mutation type."
type MutationRoot {
  """Login to the service. Requires solved captcha, with token sent via
  cookie "hCaptchaToken". If login is successful the response will include
  a set-cookie for "sessionToken". After four unsuccessful username/password
  combinations, you need to solve captcha again."""
  login(passwordBcrypt: String!, username: String!): LoginResult!
  """Place an order for purchase. Set customerId to associate order with
  logged in account. Maximum order lines: 50."""
  placeOrder(purchaseOrderToInsert: PurchaseOrderToInsert!): PurchaseOrderMutationResult!
  "Signs out of the service."
  signOut: IgnoredResult!
  """Signs up for the service. Requires solved captcha, with token sent
  via cookie "hCaptchaToken"."""
  signUp(name: String!, passwordBcrypt: String!, username: String!): SignUpResult!
  """Checks with a third party if the submitted token is a valid captcha
  response. If successful sets cookie "hCaptchaToken" and returns the token.
  Does no mutations"""
  verifyCaptcha(token: String!): UUIDTokenResult!
}
// Here is a snippet from how the autogenerated GraphQL code could be handled.

CustomerMutationRootField mutationField =
        checkOnlyOneMutation(mutationRequest);
rateLimiter.consumeRegularGraphQL(userInfo.remoteAddress(), 3);
MutationFieldAndCookies authorized = switch (mutationField) {
    // These Field types are autogenerated.
    case PlaceOrderMutationField placeOrderMutationField ->
            orderDomain.handlePlaceOrder(userInfo, placeOrderMutationField);
    case SignUpMutationField signUp ->
            userDomain.handleSignUp(userInfo, signUp);
    case SignOutMutationField signOut ->
            loginSessionDomain.handleSignOut(userInfo, signOut);
    case LoginMutationField login ->
            loginSessionDomain.handleLogin(userInfo, login);
    case VerifyCaptchaMutationField verifyCaptchaField ->
            captchaVerifier.verifyHCaptchaTokenMutation(verifyCaptchaField);
};
GraphQLExecutionResult executionResult =
        graphQLService.executeMutation(List.of(authorized.mutationField()));
                    
public MutationFieldAndCookies handlePlaceOrder(
        UserInfo userInfo, PlaceOrderMutationField placeOrderMutationField)
        throws GraphQLRequestException {
    PurchaseOrderInsertMutationInput input = placeOrderMutationField.input();
    PurchaseOrderToInsert purchaseOrderToInsert = input.purchaseOrderToInsert();

    // Validate address
    String address = purchaseOrderToInsert.getAddress();
    if(address.trim().isEmpty()){
        throw new GraphQLValidationException("Address cannot be empty.");
    }

    // Validate name
    String name = purchaseOrderToInsert.getName();
    if(name.trim().isEmpty()){
        throw new GraphQLValidationException("Name cannot be empty.");
    }

    // Additional code
public MutationFieldAndCookies verifyHCaptchaTokenMutation(
        VerifyCaptchaMutationField verifyCaptchaField)
        throws GraphQLRequestException {
    VerifyCaptchaMutationInput input = verifyCaptchaField.input();
    String responseToken = input.token();
    if(responseToken.length() > 5_000){
        throw new GraphQLRequestException("Invalid captcha token.",
                GraphQLErrorTypeCategory.OTHER);
    }

    // Additional code.

Generated code ensures type-safety

Have the compiler and IDE hold your hand while you are building your application. Instead of spending time writing mapping code, you get all the typing you need automatically from the schema.

Plain Java business logic

Use the types generated to look at what is going on and apply your business logic in plain Java.

Minimize runtime errors

Loppi makes most invalid state impossible to represent.

Developer-friendly API

Most objects are immutable and thread-safe, and method calls never accept or return null.

Newsletter

Subscribe to our newsletter by sending
subject subscribe to newsletter@loppi.io or click here.

Unsubscribe by sending unsubscribe to the same address, or click on any of the unsubscribe links in the received mail.