DEV Community

Cover image for **Java FFM API: Simplifying Native Code Integration Without JNI Complexity**
Aarav Joshi
Aarav Joshi

Posted on

**Java FFM API: Simplifying Native Code Integration Without JNI Complexity**

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

Working with native code has always been a challenge in Java development. Traditional JNI requires complex C wrappers and manual memory management that often leads to crashes and memory leaks. The Foreign Function & Memory API changes this paradigm by providing direct access to native libraries while maintaining Java's safety principles.

I've spent considerable time exploring this API since its preview introduction, and I'm consistently impressed by how it simplifies native interoperability. The FFM API eliminates the need for JNI boilerplate while providing performance that rivals direct C code execution.

Memory Segment Management

The foundation of native interoperability lies in proper memory management. Memory segments provide controlled access to off-heap memory with automatic cleanup mechanisms that prevent common native code pitfalls.

public class MemorySegmentExample {
    public void demonstrateBasicMemoryAllocation() {
        try (Arena arena = Arena.ofConfined()) {
            // Allocate memory for a C structure
            MemorySegment buffer = arena.allocate(1024);

            // Write primitive values
            buffer.set(ValueLayout.JAVA_INT, 0, 12345);
            buffer.set(ValueLayout.JAVA_DOUBLE, 4, 3.14159);

            // Read values back
            int intValue = buffer.get(ValueLayout.JAVA_INT, 0);
            double doubleValue = buffer.get(ValueLayout.JAVA_DOUBLE, 4);

            System.out.println("Integer: " + intValue);
            System.out.println("Double: " + doubleValue);
        }
        // Memory automatically freed when arena closes
    }

    public void workWithNativeArrays() {
        try (Arena arena = Arena.ofConfined()) {
            // Allocate array of 100 integers
            MemorySegment intArray = arena.allocateArray(ValueLayout.JAVA_INT, 100);

            // Fill array with values
            for (int i = 0; i < 100; i++) {
                intArray.setAtIndex(ValueLayout.JAVA_INT, i, i * i);
            }

            // Process array elements
            for (int i = 0; i < 100; i++) {
                int value = intArray.getAtIndex(ValueLayout.JAVA_INT, i);
                System.out.println("Square of " + i + " is " + value);
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Arena management provides different scoping options for memory lifetime control. Confined arenas restrict access to a single thread, while shared arenas allow multi-threaded access with appropriate synchronization.

The automatic memory cleanup eliminates common native programming errors. When an arena closes, all associated memory segments become invalid, preventing use-after-free bugs that plague traditional native code.

Native Function Binding

Direct function binding eliminates JNI overhead while maintaining type safety. The Linker API creates method handles that invoke native functions with automatic parameter marshalling.

public class NativeFunctionBinding {
    private static final Linker LINKER = Linker.nativeLinker();
    private static final SymbolLookup STDLIB = LINKER.defaultLookup();

    // Function descriptor for strlen(const char* str)
    private static final FunctionDescriptor STRLEN_DESC = 
        FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS);

    // Function descriptor for malloc(size_t size)
    private static final FunctionDescriptor MALLOC_DESC = 
        FunctionDescriptor.of(ValueLayout.ADDRESS, ValueLayout.JAVA_LONG);

    // Function descriptor for free(void* ptr)
    private static final FunctionDescriptor FREE_DESC = 
        FunctionDescriptor.ofVoid(ValueLayout.ADDRESS);

    public void demonstrateFunctionBinding() throws Throwable {
        // Bind to native strlen function
        MethodHandle strlen = LINKER.downcallHandle(
            STDLIB.find("strlen").orElseThrow(),
            STRLEN_DESC
        );

        // Bind to malloc and free
        MethodHandle malloc = LINKER.downcallHandle(
            STDLIB.find("malloc").orElseThrow(),
            MALLOC_DESC
        );

        MethodHandle free = LINKER.downcallHandle(
            STDLIB.find("free").orElseThrow(),
            FREE_DESC
        );

        try (Arena arena = Arena.ofConfined()) {
            // Create null-terminated string
            String javaString = "Hello, Native World!";
            MemorySegment cString = arena.allocateUtf8String(javaString);

            // Call native strlen function
            long length = (long) strlen.invokeExact(cString);
            System.out.println("String length: " + length);

            // Demonstrate malloc/free usage
            MemorySegment nativeMemory = (MemorySegment) malloc.invokeExact(1024L);

            if (!nativeMemory.equals(MemorySegment.NULL)) {
                // Use the allocated memory
                MemorySegment bounded = nativeMemory.reinterpret(1024);
                bounded.set(ValueLayout.JAVA_INT, 0, 42);

                int value = bounded.get(ValueLayout.JAVA_INT, 0);
                System.out.println("Value from malloc'd memory: " + value);

                // Free the memory
                free.invokeExact(nativeMemory);
            }
        }
    }

    public void demonstrateComplexFunction() throws Throwable {
        // Bind to printf function
        FunctionDescriptor printfDesc = FunctionDescriptor.of(
            ValueLayout.JAVA_INT,
            ValueLayout.ADDRESS,
            ValueLayout.JAVA_INT,
            ValueLayout.JAVA_DOUBLE
        );

        MethodHandle printf = LINKER.downcallHandle(
            STDLIB.find("printf").orElseThrow(),
            printfDesc
        );

        try (Arena arena = Arena.ofConfined()) {
            MemorySegment formatString = arena.allocateUtf8String("Number: %d, Value: %.2f\n");
            int result = (int) printf.invokeExact(formatString, 42, 3.14159);
            System.out.println("Printf returned: " + result);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Function descriptors define the native function signature, ensuring type safety during invocation. The system validates parameter types and counts at method handle creation time, catching errors early in development.

Method handle invocation provides performance comparable to direct native calls. The JVM optimizes these calls aggressively, often inlining them completely in hot code paths.

Structured Memory Layout

Structured layouts enable portable data structure definitions that work consistently across different architectures. These layouts handle alignment and padding automatically while providing clear field access patterns.

public class StructuredLayoutExample {
    // Define a C-style struct layout
    private static final GroupLayout POINT_LAYOUT = MemoryLayout.structLayout(
        ValueLayout.JAVA_DOUBLE.withName("x"),
        ValueLayout.JAVA_DOUBLE.withName("y")
    );

    private static final VarHandle X_HANDLE = POINT_LAYOUT.varHandle(
        MemoryLayout.PathElement.groupElement("x")
    );

    private static final VarHandle Y_HANDLE = POINT_LAYOUT.varHandle(
        MemoryLayout.PathElement.groupElement("y")
    );

    // More complex struct with nested structures
    private static final GroupLayout RECTANGLE_LAYOUT = MemoryLayout.structLayout(
        POINT_LAYOUT.withName("topLeft"),
        POINT_LAYOUT.withName("bottomRight"),
        ValueLayout.JAVA_INT.withName("color")
    );

    public void demonstrateStructureUsage() {
        try (Arena arena = Arena.ofConfined()) {
            // Allocate memory for point structure
            MemorySegment point = arena.allocate(POINT_LAYOUT);

            // Set field values using var handles
            X_HANDLE.set(point, 10.5);
            Y_HANDLE.set(point, 20.3);

            // Read field values
            double x = (double) X_HANDLE.get(point);
            double y = (double) Y_HANDLE.get(point);

            System.out.println("Point: (" + x + ", " + y + ")");

            // Work with array of structures
            MemorySegment pointArray = arena.allocateArray(POINT_LAYOUT, 10);

            for (int i = 0; i < 10; i++) {
                MemorySegment element = pointArray.asSlice(i * POINT_LAYOUT.byteSize(), POINT_LAYOUT);
                X_HANDLE.set(element, i * 1.0);
                Y_HANDLE.set(element, i * 2.0);
            }

            // Read back the array
            for (int i = 0; i < 10; i++) {
                MemorySegment element = pointArray.asSlice(i * POINT_LAYOUT.byteSize(), POINT_LAYOUT);
                double pointX = (double) X_HANDLE.get(element);
                double pointY = (double) Y_HANDLE.get(element);
                System.out.println("Point[" + i + "]: (" + pointX + ", " + pointY + ")");
            }
        }
    }

    public void demonstrateUnionLayout() {
        // Define a union layout (overlapping memory)
        GroupLayout unionLayout = MemoryLayout.unionLayout(
            ValueLayout.JAVA_INT.withName("intValue"),
            ValueLayout.JAVA_FLOAT.withName("floatValue"),
            MemoryLayout.sequenceLayout(4, ValueLayout.JAVA_BYTE).withName("bytes")
        );

        VarHandle intHandle = unionLayout.varHandle(
            MemoryLayout.PathElement.groupElement("intValue")
        );

        VarHandle floatHandle = unionLayout.varHandle(
            MemoryLayout.PathElement.groupElement("floatValue")
        );

        try (Arena arena = Arena.ofConfined()) {
            MemorySegment union = arena.allocate(unionLayout);

            // Write as integer
            intHandle.set(union, 0x42424242);

            // Read as float (same memory location)
            float floatValue = (float) floatHandle.get(union);
            System.out.println("Union - Int: 0x42424242, Float: " + floatValue);

            // Examine individual bytes
            for (int i = 0; i < 4; i++) {
                byte b = union.get(ValueLayout.JAVA_BYTE, i);
                System.out.printf("Byte[%d]: 0x%02X\n", i, b & 0xFF);
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

VarHandle access provides atomic operations on structure fields, enabling thread-safe manipulation of native data structures. This approach eliminates the need for external synchronization in many scenarios.

Nested structure support enables complex data modeling that mirrors C and C++ structures exactly. The layout system handles all alignment requirements automatically, ensuring binary compatibility with native libraries.

Callback Implementation

Callback mechanisms enable native code to invoke Java methods through upcall stubs. These stubs create native function pointers that safely transition execution back to Java with proper exception handling.

public class CallbackImplementation {
    // Define callback function signature
    private static final FunctionDescriptor CALLBACK_DESC = FunctionDescriptor.of(
        ValueLayout.JAVA_INT,
        ValueLayout.JAVA_INT,
        ValueLayout.JAVA_INT
    );

    // Define qsort comparator signature
    private static final FunctionDescriptor QSORT_DESC = FunctionDescriptor.ofVoid(
        ValueLayout.ADDRESS,  // array pointer
        ValueLayout.JAVA_LONG, // element count
        ValueLayout.JAVA_LONG, // element size
        ValueLayout.ADDRESS   // comparator function pointer
    );

    private static final Linker LINKER = Linker.nativeLinker();
    private static final SymbolLookup STDLIB = LINKER.defaultLookup();

    public void demonstrateSimpleCallback() throws Throwable {
        // Create Java callback implementation
        MethodHandle javaCallback = MethodHandles.lookup().findStatic(
            CallbackImplementation.class,
            "addTwoNumbers",
            MethodType.methodType(int.class, int.class, int.class)
        );

        try (Arena arena = Arena.ofConfined()) {
            // Create upcall stub
            MemorySegment callbackStub = LINKER.upcallStub(
                javaCallback,
                CALLBACK_DESC,
                arena
            );

            // Create a native function that accepts callback
            String nativeCode = """
                typedef int (*callback_t)(int, int);
                int call_callback(callback_t cb, int a, int b) {
                    return cb(a, b);
                }
                """;

            // In practice, you'd load this from a native library
            // For demonstration, we'll create a simple test
            System.out.println("Callback stub created at: " + callbackStub.address());
        }
    }

    public void demonstrateQsortCallback() throws Throwable {
        // Bind to qsort function
        MethodHandle qsort = LINKER.downcallHandle(
            STDLIB.find("qsort").orElseThrow(),
            QSORT_DESC
        );

        // Create comparator callback
        MethodHandle comparator = MethodHandles.lookup().findStatic(
            CallbackImplementation.class,
            "intComparator",
            MethodType.methodType(int.class, MemorySegment.class, MemorySegment.class)
        );

        FunctionDescriptor comparatorDesc = FunctionDescriptor.of(
            ValueLayout.JAVA_INT,
            ValueLayout.ADDRESS,
            ValueLayout.ADDRESS
        );

        try (Arena arena = Arena.ofConfined()) {
            // Create array to sort
            int[] javaArray = {64, 34, 25, 12, 22, 11, 90, 88, 76, 50};
            MemorySegment nativeArray = arena.allocateArray(ValueLayout.JAVA_INT, javaArray.length);

            // Copy Java array to native memory
            for (int i = 0; i < javaArray.length; i++) {
                nativeArray.setAtIndex(ValueLayout.JAVA_INT, i, javaArray[i]);
            }

            System.out.println("Before sorting:");
            printArray(nativeArray, javaArray.length);

            // Create upcall stub for comparator
            MemorySegment comparatorStub = LINKER.upcallStub(
                comparator,
                comparatorDesc,
                arena
            );

            // Call qsort with our callback
            qsort.invokeExact(
                nativeArray,
                (long) javaArray.length,
                (long) ValueLayout.JAVA_INT.byteSize(),
                comparatorStub
            );

            System.out.println("After sorting:");
            printArray(nativeArray, javaArray.length);
        }
    }

    public void demonstrateAsyncCallback() throws Throwable {
        // Create callback for asynchronous operations
        MethodHandle asyncHandler = MethodHandles.lookup().findStatic(
            CallbackImplementation.class,
            "handleAsyncResult",
            MethodType.methodType(void.class, int.class, MemorySegment.class)
        );

        FunctionDescriptor asyncDesc = FunctionDescriptor.ofVoid(
            ValueLayout.JAVA_INT,
            ValueLayout.ADDRESS
        );

        try (Arena arena = Arena.ofConfined()) {
            MemorySegment asyncStub = LINKER.upcallStub(
                asyncHandler,
                asyncDesc,
                arena
            );

            System.out.println("Async callback registered at: " + asyncStub.address());

            // Simulate registering callback with native library
            // Native code would call this callback when operation completes
            simulateAsyncOperation(asyncStub);
        }
    }

    // Callback implementations
    public static int addTwoNumbers(int a, int b) {
        System.out.println("Java callback called with: " + a + " + " + b);
        return a + b;
    }

    public static int intComparator(MemorySegment a, MemorySegment b) {
        int valueA = a.get(ValueLayout.JAVA_INT, 0);
        int valueB = b.get(ValueLayout.JAVA_INT, 0);
        return Integer.compare(valueA, valueB);
    }

    public static void handleAsyncResult(int status, MemorySegment data) {
        System.out.println("Async operation completed with status: " + status);
        if (!data.equals(MemorySegment.NULL)) {
            // Process result data
            System.out.println("Result data available at: " + data.address());
        }
    }

    private void printArray(MemorySegment array, int length) {
        for (int i = 0; i < length; i++) {
            int value = array.getAtIndex(ValueLayout.JAVA_INT, i);
            System.out.print(value + " ");
        }
        System.out.println();
    }

    private void simulateAsyncOperation(MemorySegment callback) {
        // Simulate calling the callback (in practice, native code would do this)
        System.out.println("Simulating async operation completion...");

        try {
            // Create method handle to invoke callback
            FunctionDescriptor callbackDesc = FunctionDescriptor.ofVoid(
                ValueLayout.JAVA_INT,
                ValueLayout.ADDRESS
            );

            MethodHandle callbackHandle = LINKER.downcallHandle(callback, callbackDesc);
            callbackHandle.invokeExact(0, MemorySegment.NULL);
        } catch (Throwable e) {
            System.err.println("Error invoking callback: " + e.getMessage());
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Exception handling in callbacks requires careful consideration since native code cannot process Java exceptions. The FFM API provides mechanisms to catch and handle exceptions within callback implementations.

Thread safety becomes crucial when callbacks execute on different threads than the creating thread. Proper synchronization ensures data consistency when callback code accesses shared Java objects.

Advanced Integration Patterns

Complex native integration scenarios require sophisticated resource management and error handling strategies. These patterns ensure robust applications that gracefully handle native library failures and resource constraints.

public class AdvancedIntegrationPatterns {
    private static final Linker LINKER = Linker.nativeLinker();
    private final Arena globalArena;
    private final Map<String, MethodHandle> nativeFunctions;

    public AdvancedIntegrationPatterns() {
        this.globalArena = Arena.ofShared();
        this.nativeFunctions = new ConcurrentHashMap<>();
        Runtime.getRuntime().addShutdownHook(new Thread(this::cleanup));
    }

    public void demonstrateResourcePooling() {
        // Create memory pool for frequent allocations
        MemoryPool pool = new MemoryPool(1024 * 1024); // 1MB pool

        try {
            // Simulate multiple operations using pooled memory
            for (int i = 0; i < 100; i++) {
                try (PooledMemory memory = pool.allocate(1024)) {
                    // Use pooled memory for native operations
                    processNativeData(memory.segment());
                }
            }
        } finally {
            pool.close();
        }
    }

    public void demonstrateErrorHandling() {
        try (Arena arena = Arena.ofConfined()) {
            // Create error-checking wrapper for native calls
            NativeLibraryWrapper wrapper = new NativeLibraryWrapper(arena);

            // Safe native function call with error checking
            wrapper.callWithErrorCheck("risky_function", () -> {
                // Native function that might fail
                return performRiskyNativeOperation(arena);
            });

        } catch (NativeException e) {
            System.err.println("Native operation failed: " + e.getMessage());
            // Handle native error appropriately
        }
    }

    public void demonstrateMultiThreadedAccess() throws InterruptedException {
        ExecutorService executor = Executors.newFixedThreadPool(4);
        CountDownLatch latch = new CountDownLatch(4);

        // Shared arena for multi-threaded access
        try (Arena sharedArena = Arena.ofShared()) {
            MemorySegment sharedBuffer = sharedArena.allocate(4096);

            // Launch multiple threads accessing native memory
            for (int i = 0; i < 4; i++) {
                final int threadId = i;
                executor.submit(() -> {
                    try {
                        // Each thread works on different section
                        long offset = threadId * 1024L;
                        MemorySegment section = sharedBuffer.asSlice(offset, 1024);

                        // Perform thread-safe operations
                        processMemorySection(section, threadId);

                    } finally {
                        latch.countDown();
                    }
                });
            }

            latch.await();
        }

        executor.shutdown();
        executor.awaitTermination(5, TimeUnit.SECONDS);
    }

    public void demonstratePerformanceOptimization() throws Throwable {
        // Cache frequently used method handles
        MethodHandle optimizedFunction = getOrCreateMethodHandle("optimized_function");

        try (Arena arena = Arena.ofConfined()) {
            // Pre-allocate memory for batch operations
            MemorySegment batchBuffer = arena.allocate(1024 * 1024);

            // Perform batch operations to amortize overhead
            long startTime = System.nanoTime();

            for (int batch = 0; batch < 1000; batch++) {
                // Process data in batches to reduce native call overhead
                long offset = (batch % 1024) * 1024L;
                MemorySegment workBuffer = batchBuffer.asSlice(offset, 1024);

                // Single native call processes multiple items
                optimizedFunction.invokeExact(workBuffer, 128L); // 128 items per batch
            }

            long endTime = System.nanoTime();
            double milliseconds = (endTime - startTime) / 1_000_000.0;
            System.out.println("Batch processing completed in: " + milliseconds + "ms");
        }
    }

    private void processNativeData(MemorySegment memory) {
        // Simulate native data processing
        memory.fill((byte) 0);

        for (int i = 0; i < 256; i++) {
            memory.set(ValueLayout.JAVA_INT, i * 4L, i);
        }
    }

    private boolean performRiskyNativeOperation(Arena arena) {
        // Simulate operation that might fail
        MemorySegment testMemory = arena.allocate(100);

        try {
            // Simulate potential failure conditions
            if (Math.random() < 0.1) {
                throw new RuntimeException("Simulated native failure");
            }

            testMemory.set(ValueLayout.JAVA_INT, 0, 42);
            return true;

        } catch (Exception e) {
            return false;
        }
    }

    private void processMemorySection(MemorySegment section, int threadId) {
        // Thread-safe processing of memory section
        synchronized (this) {
            section.set(ValueLayout.JAVA_INT, 0, threadId);

            // Simulate processing time
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }

    private MethodHandle getOrCreateMethodHandle(String functionName) throws Throwable {
        return nativeFunctions.computeIfAbsent(functionName, name -> {
            try {
                SymbolLookup lookup = LINKER.defaultLookup();
                Optional<MemorySegment> symbol = lookup.find(name);

                if (symbol.isPresent()) {
                    FunctionDescriptor desc = FunctionDescriptor.of(
                        ValueLayout.JAVA_INT,
                        ValueLayout.ADDRESS,
                        ValueLayout.JAVA_LONG
                    );

                    return LINKER.downcallHandle(symbol.get(), desc);
                } else {
                    // Return mock handle for demonstration
                    return MethodHandles.empty(MethodType.methodType(int.class, MemorySegment.class, long.class));
                }
            } catch (Exception e) {
                throw new RuntimeException("Failed to create method handle", e);
            }
        });
    }

    private void cleanup() {
        if (globalArena != null && globalArena.scope().isAlive()) {
            globalArena.close();
        }
    }

    // Custom exception for native operations
    static class NativeException extends Exception {
        public NativeException(String message) {
            super(message);
        }

        public NativeException(String message, Throwable cause) {
            super(message, cause);
        }
    }

    // Memory pool implementation for efficient allocation
    static class MemoryPool implements AutoCloseable {
        private final Arena arena;
        private final MemorySegment pool;
        private final AtomicLong offset;
        private final long size;

        public MemoryPool(long size) {
            this.arena = Arena.ofShared();
            this.pool = arena.allocate(size);
            this.offset = new AtomicLong(0);
            this.size = size;
        }

        public PooledMemory allocate(long requestedSize) {
            long currentOffset = offset.getAndAdd(requestedSize);

            if (currentOffset + requestedSize > size) {
                throw new OutOfMemoryError("Pool exhausted");
            }

            MemorySegment segment = pool.asSlice(currentOffset, requestedSize);
            return new PooledMemory(segment, this);
        }

        @Override
        public void close() {
            arena.close();
        }
    }

    // Wrapper for pooled memory segments
    static class PooledMemory implements AutoCloseable {
        private final MemorySegment segment;
        private final MemoryPool pool;

        public PooledMemory(MemorySegment segment, MemoryPool pool) {
            this.segment = segment;
            this.pool = pool;
        }

        public MemorySegment segment() {
            return segment;
        }

        @Override
        public void close() {
            // Pool manages the underlying memory
            // Individual segments don't need explicit cleanup
        }
    }

    // Wrapper for safe native library operations
    static class NativeLibraryWrapper {
        private final Arena arena;

        public NativeLibraryWrapper(Arena arena) {
            this.arena = arena;
        }

        public void callWithErrorCheck(String operation, Supplier<Boolean> nativeCall) throws NativeException {
            try {
                boolean success = nativeCall.get();
                if (!success) {
                    throw new NativeException("Native operation failed: " + operation);
                }
            } catch (Exception e) {
                throw new NativeException("Error during native operation: " + operation, e);
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The FFM API represents a significant advancement in Java's native interoperability capabilities. By eliminating JNI complexity while maintaining safety guarantees, it opens new possibilities for Java applications to leverage native libraries and system resources efficiently.

Performance characteristics often exceed traditional JNI implementations due to reduced marshalling overhead and optimized native call paths. The type-safe design prevents common native programming errors while providing direct access to system-level functionality.

Memory management becomes more intuitive with arena-based allocation patterns. Developers can reason about memory lifetime more easily, reducing the likelihood of leaks and corruption that plague traditional native code integration.

The callback system enables sophisticated integration patterns where native libraries can invoke Java code seamlessly. This bidirectional communication supports complex use cases like event handling, progress reporting, and asynchronous operations.

As this API continues to evolve, I expect it to become the preferred method for native integration in Java applications. The combination of performance, safety, and usability makes it an excellent choice for modern Java development requiring native library access.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)