Migrating Legacy CRUD Apps to qqq

A practical guide to migrating existing applications to qqq, including strategies for database compatibility, gradual rollout, and handling custom logic.

Darin Kelkhoff
Darin Kelkhoff - Co-Founder
Migrating Legacy CRUD Apps to qqq

Migrating a legacy application to a new framework is often seen as a risky, all-or-nothing endeavor. With qqq, it doesn't have to be. Our database-first approach means you can point qqq at your existing database and start generating applications immediately, migrating features incrementally.

The key to successful migration is not replacing everything at once. It's running old and new systems in parallel, validating behavior, and switching over when you're confident.

Why Migrate to qqq?

Before diving into the how, let's address the why. Legacy CRUD applications often suffer from:

  • Boilerplate overload: Thousands of lines of repetitive code for basic operations
  • Inconsistent patterns: Different developers, different approaches
  • Maintenance burden: Simple changes require touching multiple files
  • Missing features: No audit trails, weak permissions, no API

qqq addresses all of these by generating functionality from metadata while letting you customize where needed.

Step 1: Assess Your Current Application

Start by cataloging what you have:

java
// Common patterns in legacy apps:
// 1. Entity classes with JPA annotations
// 2. Repository interfaces
// 3. Service classes with business logic
// 4. Controller classes for REST endpoints
// 5. Custom SQL queries

For each entity, note:

  • Database table name and schema
  • Field types and constraints
  • Relationships to other tables
  • Custom business logic in services
  • Any stored procedures or triggers

Step 2: Generate Metadata from Your Schema

qqq can introspect your existing database and generate table metadata:

java
// Connect qqq to your existing database
QBackendMetaData backend = new QBackendMetaData()
   .withName("legacy")
   .withBackendType("RDBMS")
   .withConnection(new DatabaseConnection()
      .withDatabaseType(DatabaseType.POSTGRESQL)
      .withJdbcUrl("jdbc:postgresql://localhost:5432/legacy_app")
      .withUsername("app_user")
      .withPassword("password")
   );
 
// Generate table metadata from schema
List<QTableMetaData> tables = SchemaIntrospector.introspect(backend);

This gives you a working application with:

  • All your tables defined
  • Correct field types mapped
  • Basic CRUD operations
  • REST API endpoints
  • Admin UI

Step 3: Run qqq Alongside Your Legacy App

Don't switch over immediately. Run both systems:

java
// Your legacy app continues running on port 8080
// Start qqq on a different port for comparison
QApplication.start(qInstance, 8081);

This lets you:

  • Compare behavior between systems
  • Validate data consistency
  • Train users on the new interface
  • Roll back instantly if needed

Step 4: Migrate Business Logic Incrementally

Custom business logic is where migrations get tricky. In qqq, business logic lives in processes:

java
// Legacy service method
public class OrderService {
   public void approveOrder(Long orderId) {
      Order order = orderRepository.findById(orderId);
      if (order.getTotal() > 10000) {
         throw new Exception("Orders over $10,000 require manager approval");
      }
      order.setStatus("APPROVED");
      order.setApprovedAt(Instant.now());
      orderRepository.save(order);
      emailService.sendApprovalNotification(order);
   }
}
 
// Equivalent qqq process
public class ApproveOrderProcess implements MetaDataProducerInterface<QProcessMetaData> {
   @Override
   public QProcessMetaData produce(QInstance qInstance) {
      return new QProcessMetaData()
         .withName("approveOrder")
         .withTableName("order")
         .withSteps(List.of(
            new QBackendStepMetaData()
               .withName("validate")
               .withCode(new ValidateOrderStep()),
            new QBackendStepMetaData()
               .withName("approve")
               .withCode(new ApproveOrderStep()),
            new QBackendStepMetaData()
               .withName("notify")
               .withCode(new SendNotificationStep())
         ));
   }
}

The process approach is more explicit about steps and allows for better error handling, retries, and audit trails.

Step 5: Handle Custom Queries

Legacy apps often have complex custom queries. qqq's query filter handles most cases:

java
// Legacy: Custom JPQL query
@Query("SELECT o FROM Order o WHERE o.customer.region = :region AND o.total > :minTotal")
List<Order> findLargeOrdersByRegion(String region, BigDecimal minTotal);
 
// qqq equivalent
QueryInput queryInput = new QueryInput()
   .withTableName("order")
   .withFilter(new QQueryFilter()
      .withCriteria(new QFilterCriteria("customer.region", QCriteriaOperator.EQUALS, region))
      .withCriteria(new QFilterCriteria("total", QCriteriaOperator.GREATER_THAN, minTotal))
   );

For truly complex queries (unions, CTEs, window functions), you can still use raw SQL through custom backends.

Step 6: Migrate Authentication

If your legacy app uses Spring Security or similar:

java
// Map existing users to qqq's auth system
qInstance.withAuthentication(new QAuthenticationMetaData()
   .withType(QAuthenticationType.TABLE_BASED)
   .withUserTableName("users")  // Your existing user table
   .withUsernameField("email")
   .withPasswordField("password_hash")
);

qqq supports bcrypt, scrypt, and argon2 password hashing. For OAuth2 or SAML, configure the appropriate provider.

Step 7: Enable Features You've Always Wanted

Once migrated, enable capabilities that would have taken months to build:

java
// Audit logging - track every change
table.withAuditRules(new QAuditRules()
   .withAuditLevel(AuditLevel.FIELD));
 
// Row-level security - users only see their data
table.withRecordSecurityLock(new QRecordSecurityLock()
   .withFieldName("ownerId")
   .withLockType(LockType.READ_AND_WRITE));
 
// Scheduled processes - automated jobs
process.withSchedule(new QScheduleMetaData()
   .withCronExpression("0 0 * * *"));  // Daily at midnight

Common Migration Challenges

Challenge: Legacy app has stored procedures Solution: Call them from qqq processes, or migrate logic to Java steps

Challenge: Complex authorization rules Solution: Use qqq's permission system with custom security key providers

Challenge: Non-standard data types Solution: Create custom field type converters

Challenge: Large data volumes Solution: Use qqq's streaming APIs for batch operations

Migration Checklist

  • [ ] Document all tables, fields, and relationships
  • [ ] Identify custom business logic to migrate
  • [ ] Set up qqq alongside legacy system
  • [ ] Generate metadata from schema
  • [ ] Validate CRUD operations match legacy behavior
  • [ ] Migrate business logic as processes
  • [ ] Configure authentication
  • [ ] Enable audit logging
  • [ ] Run parallel testing period
  • [ ] Switch traffic to qqq
  • [ ] Decommission legacy system

Conclusion

Migrating to qqq doesn't require a big bang cutover. Start with the database you have, generate an application, and incrementally add features. The metadata-driven approach means you're not locked into patterns that don't fit your needs.

Most teams complete migration in weeks, not months, and end up with an application that's more maintainable, more feature-rich, and easier to extend than what they started with.

Ready to start? Check out our quickstart guide or join our Discord for migration support.

Read next

Ready to build faster?