Build a Complete Enterprise App in a Weekend

Build a full Order Management system with authentication, RBAC, audit logging, and a REST API - all in a weekend

In this tutorial, you'll build a complete Order Management System (OMS) from scratch using qqq. By the end, you'll have a fully functional enterprise application with:

  • Complete CRUD operations for Orders, Customers, and Products
  • User authentication with secure login
  • Role-based access control (Admin, Sales, Viewer roles)
  • Audit logging for compliance
  • Auto-generated REST API with documentation

Let's get started.

Prerequisites

Before you begin, make sure you have:

  • Java 21+ installed (java -version to check)
  • Maven 3.6+ installed (mvn -version to check)
  • Your favorite IDE (IntelliJ IDEA recommended)
  • About 4-6 hours of focused time

Step 1: Create Your Project

Start by creating a new qqq project using the Maven archetype:

bash
mvn archetype:generate \
  -DarchetypeGroupId=io.qrun \
  -DarchetypeArtifactId=qqq-archetype \
  -DgroupId=com.example \
  -DartifactId=my-oms \
  -Dversion=1.0-SNAPSHOT

Navigate into your new project:

bash
cd my-oms

Your project structure looks like this:

my-oms/
├── pom.xml
├── src/main/java/com/example/
│   ├── MyOmsApplication.java
│   └── metadata/
│       └── MyOmsMetaDataProvider.java
└── src/main/resources/
    └── application.properties

Step 2: Define Your Data Model

The heart of any qqq application is the metadata. Let's define our tables.

Customer Table

Create src/main/java/com/example/metadata/tables/CustomerTableMetaDataProducer.java:

java
public class CustomerTableMetaDataProducer implements MetaDataProducerInterface<QTableMetaData>
{
   public static final String NAME = "customer";
 
   @Override
   public QTableMetaData produce(QInstance qInstance)
   {
      return new QTableMetaData()
         .withName(NAME)
         .withLabel("Customer")
         .withPrimaryKeyField("id")
         .withFields(List.of(
            new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false),
            new QFieldMetaData("name", QFieldType.STRING).withIsRequired(true),
            new QFieldMetaData("email", QFieldType.STRING).withIsRequired(true),
            new QFieldMetaData("phone", QFieldType.STRING),
            new QFieldMetaData("address", QFieldType.STRING),
            new QFieldMetaData("createDate", QFieldType.DATE_TIME).withIsEditable(false),
            new QFieldMetaData("modifyDate", QFieldType.DATE_TIME).withIsEditable(false)
         ));
   }
}

Product Table

Create src/main/java/com/example/metadata/tables/ProductTableMetaDataProducer.java:

java
public class ProductTableMetaDataProducer implements MetaDataProducerInterface<QTableMetaData>
{
   public static final String NAME = "product";
 
   @Override
   public QTableMetaData produce(QInstance qInstance)
   {
      return new QTableMetaData()
         .withName(NAME)
         .withLabel("Product")
         .withPrimaryKeyField("id")
         .withFields(List.of(
            new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false),
            new QFieldMetaData("sku", QFieldType.STRING).withIsRequired(true),
            new QFieldMetaData("name", QFieldType.STRING).withIsRequired(true),
            new QFieldMetaData("description", QFieldType.STRING),
            new QFieldMetaData("price", QFieldType.DECIMAL).withIsRequired(true),
            new QFieldMetaData("stockQuantity", QFieldType.INTEGER).withDefaultValue(0)
         ));
   }
}

Order Table

Create src/main/java/com/example/metadata/tables/OrderTableMetaDataProducer.java:

java
public class OrderTableMetaDataProducer implements MetaDataProducerInterface<QTableMetaData>
{
   public static final String NAME = "order";
 
   @Override
   public QTableMetaData produce(QInstance qInstance)
   {
      return new QTableMetaData()
         .withName(NAME)
         .withLabel("Order")
         .withPrimaryKeyField("id")
         .withFields(List.of(
            new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false),
            new QFieldMetaData("orderNumber", QFieldType.STRING).withIsRequired(true),
            new QFieldMetaData("customerId", QFieldType.INTEGER).withIsRequired(true),
            new QFieldMetaData("orderDate", QFieldType.DATE_TIME).withIsRequired(true),
            new QFieldMetaData("status", QFieldType.STRING)
               .withPossibleValueSourceName("orderStatus"),
            new QFieldMetaData("subtotal", QFieldType.DECIMAL),
            new QFieldMetaData("tax", QFieldType.DECIMAL),
            new QFieldMetaData("total", QFieldType.DECIMAL),
            new QFieldMetaData("notes", QFieldType.TEXT)
         ))
         .withAssociation(new Association()
            .withName("customer")
            .withAssociatedTableName(CustomerTableMetaDataProducer.NAME)
            .withJoinName("orderToCustomer"));
   }
}

Order Line Table

Create src/main/java/com/example/metadata/tables/OrderLineTableMetaDataProducer.java:

java
public class OrderLineTableMetaDataProducer implements MetaDataProducerInterface<QTableMetaData>
{
   public static final String NAME = "orderLine";
 
   @Override
   public QTableMetaData produce(QInstance qInstance)
   {
      return new QTableMetaData()
         .withName(NAME)
         .withLabel("Order Line")
         .withPrimaryKeyField("id")
         .withFields(List.of(
            new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false),
            new QFieldMetaData("orderId", QFieldType.INTEGER).withIsRequired(true),
            new QFieldMetaData("productId", QFieldType.INTEGER).withIsRequired(true),
            new QFieldMetaData("quantity", QFieldType.INTEGER).withIsRequired(true),
            new QFieldMetaData("unitPrice", QFieldType.DECIMAL).withIsRequired(true),
            new QFieldMetaData("lineTotal", QFieldType.DECIMAL)
         ));
   }
}

Step 3: Run Your Application

With just the metadata defined, you already have a working application. Run it:

bash
mvn compile exec:java -Dexec.mainClass="com.example.MyOmsApplication"

Open your browser to http://localhost:8080 and you'll see a fully functional admin UI with:

  • Tables for Customers, Products, Orders, and Order Lines
  • Create, Read, Update, Delete operations
  • Search and filtering
  • Sorting and pagination

That's the magic of qqq - your metadata drives everything.

Step 4: Add Business Logic

Let's add some business rules. Create a process to calculate order totals.

Calculate Order Total Process

Create src/main/java/com/example/processes/CalculateOrderTotalProcess.java:

java
public class CalculateOrderTotalProcess implements RecordCustomizerInterface
{
   @Override
   public List<QRecord> preInsert(InsertInput insertInput, List<QRecord> records)
   {
      return calculateTotals(records);
   }
 
   @Override
   public List<QRecord> preUpdate(UpdateInput updateInput, List<QRecord> records)
   {
      return calculateTotals(records);
   }
 
   private List<QRecord> calculateTotals(List<QRecord> records)
   {
      for (QRecord record : records)
      {
         BigDecimal subtotal = record.getValueBigDecimal("subtotal");
         if (subtotal != null)
         {
            BigDecimal tax = subtotal.multiply(new BigDecimal("0.08"));
            BigDecimal total = subtotal.add(tax);
            record.setValue("tax", tax);
            record.setValue("total", total);
         }
      }
      return records;
   }
}

Register it in your Order table metadata:

java
.withRecordCustomizer(new CalculateOrderTotalProcess())

Step 5: Add Order Status Workflow

Define valid status transitions for orders:

java
public class OrderStatusPossibleValueSource implements MetaDataProducerInterface<QPossibleValueSource>
{
   @Override
   public QPossibleValueSource produce(QInstance qInstance)
   {
      return new QPossibleValueSource()
         .withName("orderStatus")
         .withType(QPossibleValueSourceType.ENUM)
         .withEnumValues(List.of(
            new QPossibleValue("DRAFT", "Draft"),
            new QPossibleValue("PENDING", "Pending"),
            new QPossibleValue("APPROVED", "Approved"),
            new QPossibleValue("SHIPPED", "Shipped"),
            new QPossibleValue("DELIVERED", "Delivered"),
            new QPossibleValue("CANCELLED", "Cancelled")
         ));
   }
}

Step 6: Add Authentication

qqq makes authentication simple. Add the auth module to your pom.xml:

xml
<dependency>
   <groupId>io.qrun</groupId>
   <artifactId>qqq-backend-module-auth</artifactId>
   <version>${qqq.version}</version>
</dependency>

Configure authentication in your MetaDataProvider:

java
qInstance.withAuthentication(new QAuthenticationMetaData()
   .withName("auth")
   .withType(QAuthenticationType.TABLE_BASED)
   .withUserTableName("user")
   .withPasswordFieldName("passwordHash"));

Now users must log in to access your application.

Step 7: Configure Role-Based Access Control

Define roles and permissions:

java
qInstance.withSecurityKeyType(new QSecurityKeyType()
   .withName("role")
   .withPossibleValueSourceName("userRole"));
 
// Admin role - full access
qInstance.addPermission(new QPermission()
   .withName("admin")
   .withTablePermissions(List.of(
      new QTablePermission()
         .withTableName("*")
         .withAllowAll(true)
   )));
 
// Sales role - can manage orders and customers, view products
qInstance.addPermission(new QPermission()
   .withName("sales")
   .withTablePermissions(List.of(
      new QTablePermission()
         .withTableName("order")
         .withAllowInsert(true).withAllowUpdate(true).withAllowRead(true),
      new QTablePermission()
         .withTableName("customer")
         .withAllowInsert(true).withAllowUpdate(true).withAllowRead(true),
      new QTablePermission()
         .withTableName("product")
         .withAllowRead(true)
   )));
 
// Viewer role - read-only access
qInstance.addPermission(new QPermission()
   .withName("viewer")
   .withTablePermissions(List.of(
      new QTablePermission()
         .withTableName("*")
         .withAllowRead(true)
   )));

Step 8: Enable Audit Logging

For compliance and debugging, enable audit logging:

java
qInstance.withAuditRules(new QAuditRules()
   .withAuditLevel(AuditLevel.RECORD));

This automatically tracks:

  • Who made changes
  • When changes were made
  • What changed (old vs new values)
  • All in a searchable audit log table

Step 9: Your REST API

qqq automatically generates a REST API for all your tables. Access it at:

GET    /api/customer          # List customers
GET    /api/customer/{id}     # Get customer by ID
POST   /api/customer          # Create customer
PUT    /api/customer/{id}     # Update customer
DELETE /api/customer/{id}     # Delete customer

The same endpoints exist for all your tables. API documentation is auto-generated at /api/docs.

Test it with curl:

bash
# Create a customer
curl -X POST http://localhost:8080/api/customer \
  -H "Content-Type: application/json" \
  -d '{"name": "Acme Corp", "email": "orders@acme.com"}'
 
# List all orders
curl http://localhost:8080/api/order

Step 10: Deploy Your Application

Self-Hosted

Package your application as a JAR:

bash
mvn clean package
java -jar target/my-oms-1.0-SNAPSHOT.jar

qRun Managed Hosting

For zero-DevOps deployment, push to qRun:

bash
qrun deploy --app my-oms

Your app is now live with automatic scaling, backups, and updates.

What You Built

In just a few hours, you created an enterprise-grade application with:

  • 4 database tables with relationships
  • Full CRUD operations via UI and API
  • User authentication with secure sessions
  • Role-based permissions (Admin, Sales, Viewer)
  • Audit logging for compliance
  • Auto-generated REST API with documentation
  • Production-ready deployment options

Compare this to the weeks or months it would take with traditional frameworks.

Next Steps

Now that you have a working OMS, consider:

  1. Add the OMS qBit - Get pre-built features like invoicing, shipping integration, and reporting
  2. Explore other qBits - Browse the qBits Catalog for additional functionality
  3. Build your own qBit - Learn to create custom qBits
  4. Join the community - Get help and share your work on Discord

Happy building!